XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEEDToolBox

【WinUI】XFE精英危险工具箱

公开
关注 0 Fork 0 Star 0
返回提交历史

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
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

14 个文件 +590 -48
Modified XFEEDToolBox.Core/Navigation/FlightAutomationCoordinator.cs +73 -16
@@ -29,35 +29,67 @@ public interface IFlightJourneyDriver:IFlightDepartureDriver
29 29 }
30 30
31 31 /// <summary>One owner and cancellation scope for departure, jumps, local flight and docking, regardless of UI entry.</summary>
32 public sealed class FlightAutomationCoordinator(Func<PlayerState> player)
32 public sealed class FlightAutomationCoordinator(Func<PlayerState> player,Func<DateTimeOffset>? clock=null)
33 33 {
34 34 private readonly object gate=new();
35 private readonly Func<DateTimeOffset> time=clock??(()=>DateTimeOffset.UtcNow);
35 36 private CancellationTokenSource? active;
36 private FlightSafetyPauseException? protectionPause;
37 private Exception? monitoringPause;
37 38 private bool manualStop;
38 public NavigationRunner Interstellar{get;}=new(player);
39 private CancellationToken activeParent;
40 private string runId="";
41 private FlightAutomationStopRecord? lastStop;
42 public FlightAutomationStopRecord? LastStop{get{lock(gate)return lastStop;}}
43 public NavigationRunner Interstellar{get;}=new(player,clock);
39 44 public SystemNavigationRunner Local{get;}=new(player);
40 45 public FlightAutomationEntry? ActiveEntry{get;private set;}
41 46 public FlightAutomationStage Stage{get;private set;}
42 47 public bool IsActive{get{lock(gate)return active is not null;}}
43 48 public bool IsControllingFlight=>IsActive&&Stage is FlightAutomationStage.Departing or FlightAutomationStage.Interstellar or FlightAutomationStage.Local;
44 49 public event Action<FlightAutomationProgress>? Progress;
50 public event Action<FlightAutomationStopRecord>? StopRecorded;
51 public bool IsAwaitingInputFreeJump(PlayerState current,DateTimeOffset now)
52 {
53 lock(gate)if(active is null||active.IsCancellationRequested||activeParent.IsCancellationRequested||manualStop
54 ||ActiveEntry is null||Stage!=FlightAutomationStage.Interstellar)return false;
55 return Interstellar.IsAwaitingInputFreeJump(current,now);
56 }
57 private FlightAutomationStopRecord RecordStopLocked(string source,Exception? cause,bool veto=false)
58 {
59 var now=time();
60 if(lastStop is null)
61 {
62 FlightStopSnapshot? snapshot=null;try{snapshot=FlightStopSnapshot.Capture(player());}catch{/* A diagnostic read cannot prevent cancellation. */}
63 lastStop=new(runId,source,now,ActiveEntry,Stage,activeParent.IsCancellationRequested,cause?.Message??(veto?"用户停止自动驾驶。":"上游已取消自动驾驶。"),cause?.GetType().FullName,snapshot)
64 {Cause=cause,ManualVeto=veto,ManualVetoAt=veto?now:null};
65 }
66 else if(veto&&!lastStop.ManualVeto||activeParent.IsCancellationRequested&&!lastStop.ParentTokenCancelled)
67 lastStop=lastStop with{ManualVeto=lastStop.ManualVeto||veto,ManualVetoAt=lastStop.ManualVetoAt??(veto?now:null),ParentTokenCancelled=lastStop.ParentTokenCancelled||activeParent.IsCancellationRequested};
68 return lastStop;
69 }
70 private void PublishStop(FlightAutomationStopRecord record)
71 {if(StopRecorded is {} handlers)foreach(Action<FlightAutomationStopRecord> handler in handlers.GetInvocationList())try{handler(record);}catch{/* Logging cannot alter cancellation authority. */}}
45 72 public void Stop()
46 73 {
74 CancellationTokenSource stop;FlightAutomationStopRecord record;
47 75 lock(gate)
48 76 {
49 77 if(active is null)return;
50 manualStop=true;protectionPause=null;active.Cancel();
78 manualStop=true;record=RecordStopLocked("manual-stop",null,veto:true);stop=active;
51 79 }
80 PublishStop(record);try{stop.Cancel();}catch(ObjectDisposedException){/* This exact run already finished. */}
52 81 }
53 82 public void PauseForSafety(FlightSafetyPauseException reason)
83 =>PauseForMonitoring(reason);
84 public void PauseForMonitoring(Exception cause)
54 85 {
55 ArgumentNullException.ThrowIfNull(reason);
86 ArgumentNullException.ThrowIfNull(cause);CancellationTokenSource stop;FlightAutomationStopRecord record;
56 87 lock(gate)
57 88 {
58 89 if(active is null||manualStop)return;
59 protectionPause??=reason;active.Cancel();
90 monitoringPause??=cause;record=RecordStopLocked("monitoring-pause",monitoringPause);stop=active;
60 91 }
92 PublishStop(record);try{stop.Cancel();}catch(ObjectDisposedException){/* This exact run already finished. */}
61 93 }
62 94
63 95 public Task<FlightAutomationResult> NavigateGalaxyAsync(string destination,IFlightControlDriver driver,IFlightDepartureDriver departure,CancellationToken token=default)
@@ -260,13 +292,20 @@ public sealed class FlightAutomationCoordinator(Func<PlayerState> player)
260 292 lock(gate)
261 293 {
262 294 if(active is not null)throw new InvalidOperationException("已有自动驾驶正在运行,请先停止当前航程。");
263 stop=CancellationTokenSource.CreateLinkedTokenSource(token);active=stop;ActiveEntry=entry;protectionPause=null;manualStop=false;
295 stop=CancellationTokenSource.CreateLinkedTokenSource(token);active=stop;activeParent=token;ActiveEntry=entry;Stage=FlightAutomationStage.Preparing;
296 monitoringPause=null;manualStop=false;lastStop=null;runId=Guid.NewGuid().ToString("N");
264 297 }
265 void Report(FlightAutomationStage stage,string message){Stage=stage;Progress?.Invoke(new(entry,stage,destination,message));}
298 var thisRun=runId;
299 using var parentRegistration=token.Register(()=>
300 {
301 FlightAutomationStopRecord? record=null;lock(gate)if(ReferenceEquals(active,stop)&&runId==thisRun)record=RecordStopLocked("parent-token",null);
302 if(record is not null)PublishStop(record);
303 });
304 void Report(FlightAutomationStage stage,string message){lock(gate)Stage=stage;Progress?.Invoke(new(entry,stage,destination,message));}
266 305 FlightAutomationResult CancellationResult()
267 306 {
268 307 lock(gate)
269 return !token.IsCancellationRequested&&!manualStop&&protectionPause is {} reason
308 return !token.IsCancellationRequested&&!manualStop&&monitoringPause is {} reason
270 309 ?new(NavigationRunState.Failed,reason):new(NavigationRunState.Stopped);
271 310 }
272 311 PlayerState? initialState=null;
@@ -283,12 +322,27 @@ public sealed class FlightAutomationCoordinator(Func<PlayerState> player)
283 322 stop.Token.ThrowIfCancellationRequested();var initial=player();RequireSession(initial);initialState=initial;
284 323 Report(FlightAutomationStage.Preparing,"正在核对飞船、航程与自动驾驶状态。");
285 324 var result=await operation(initial,stop.Token,Report);
286 // Inner navigation runners release their inputs and consume their
287 // cancellation. Preserve only an explicitly requested safety pause.
288 if(result.State!=NavigationRunState.Completed&&stop.IsCancellationRequested)
325 if(result.State==NavigationRunState.Stopped&&lastStop is null)
326 {
327 FlightAutomationStopRecord? record=null;
328 lock(gate)
329 {
330 if(lastStop is null)
331 {
332 var runner=Stage==FlightAutomationStage.Interstellar?Interstellar.LastStop:null;
333 record=lastStop=runner is not null?runner with{RunId=runId,Entry=ActiveEntry,Stage=Stage,ParentTokenCancelled=token.IsCancellationRequested}
334 :RecordStopLocked("inner-runner-stopped",null);
335 }
336 }
337 if(record is not null)PublishStop(record);
338 }
339 // Inner runners consume cancellation after releasing their inputs.
340 // Preserve the first monitor cause; cleanup cannot replace it with
341 // a generic stopped result or a later transport error.
342 if(stop.IsCancellationRequested)
289 343 {
290 344 var cancelled=CancellationResult();
291 if(cancelled.Failure is null||result.State==NavigationRunState.Stopped&&result.Failure is null)result=cancelled;
345 result=cancelled;
292 346 }
293 347 MarkDeparture(result.Failure);
294 348 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)
301 355 }
302 356 catch(Exception error)
303 357 {
304 if(stop.IsCancellationRequested&&CancellationResult().Failure is null)
305 {Report(FlightAutomationStage.Stopped,"自动驾驶已停止。");return new(NavigationRunState.Stopped);}
358 if(stop.IsCancellationRequested)
359 {
360 var cancelled=CancellationResult();MarkDeparture(cancelled.Failure);
361 Report(cancelled.Failure is null?FlightAutomationStage.Stopped:FlightAutomationStage.Failed,cancelled.Failure?.Message??"自动驾驶已停止。");return cancelled;
362 }
306 363 MarkDeparture(error);Report(FlightAutomationStage.Failed,error.Message);return new(NavigationRunState.Failed,error);
307 364 }
308 finally{lock(gate){active=null;ActiveEntry=null;protectionPause=null;manualStop=false;}stop.Dispose();}
365 finally{lock(gate){active=null;ActiveEntry=null;activeParent=default;monitoringPause=null;manualStop=false;}stop.Dispose();}
309 366 }
310 367 }
Added XFEEDToolBox.Core/Navigation/FlightAutomationStopRecord.cs +47 -0
@@ -0,0 +1,47 @@
1 using System.Text.Json.Serialization;
2 using XFEEDToolBox.Core.Models;
3
4 namespace XFEEDToolBox.Core.Navigation;
5
6 public sealed record FlightStopSnapshot(string CommanderId,long? ShipId,DateTimeOffset? LoadGameAt,string GameMode,bool InGame,bool Docked,int CargoCount,
7 long? CurrentSystemAddress,long? SelectedSystemAddress,int RouteCount,long[] RoutePrefix,long? RouteDestination,bool JumpInProgress,
8 long? JumpTargetSystemAddress,string JumpTargetSystem,DateTimeOffset? JumpStartedAt,DateTimeOffset? LastJumpAt)
9 {
10 public static FlightStopSnapshot Capture(PlayerState state)=>new(state.CommanderId,state.CurrentShipId,state.LoadGameAt,state.GameMode,state.InGame,state.Docked,state.CargoCount,
11 state.Navigation.CurrentSystemAddress,state.Navigation.TargetSystemAddress,state.Navigation.Route.Count,state.Navigation.Route.Take(16).Select(point=>point.SystemAddress).ToArray(),
12 state.Navigation.Route.LastOrDefault()?.SystemAddress,state.Navigation.JumpInProgress,state.Navigation.JumpTargetSystemAddress,state.Navigation.JumpTargetSystem,
13 state.Navigation.JumpStartedAt,state.Navigation.LastJumpAt);
14 }
15
16 /// <summary>The first cause is immutable; explicit manual cancellation can add a veto without erasing that evidence.</summary>
17 public sealed record FlightAutomationStopRecord(string RunId,string Source,DateTimeOffset At,FlightAutomationEntry? Entry,FlightAutomationStage Stage,
18 bool ParentTokenCancelled,string Reason,string? CauseType,FlightStopSnapshot? Snapshot)
19 {
20 [JsonIgnore]public Exception? Cause{get;init;}
21 public bool ManualVeto{get;init;}
22 public DateTimeOffset? ManualVetoAt{get;init;}
23 }
24
25 /// <summary>Read-only proof that this runner already released inputs for this exact, bounded Journal jump.</summary>
26 public sealed record InputFreeJumpSnapshot(string CommanderId,long ShipId,DateTimeOffset LoadGameAt,string GameMode,long OriginSystemAddress,
27 long TargetSystemAddress,string TargetSystem,DateTimeOffset JumpStartedAt,DateTimeOffset WaitingSince,bool InputsReleased)
28 {
29 public static readonly TimeSpan MaximumWait=TimeSpan.FromSeconds(75);
30 public DateTimeOffset Deadline=>WaitingSince+MaximumWait;
31 public bool Matches(PlayerState current,DateTimeOffset now)=>InputsReleased&&now>=WaitingSince&&now<=Deadline
32 &&current.InGame&&!current.Docked&&current.CommanderId==CommanderId&&current.CurrentShipId==ShipId&&current.LoadGameAt==LoadGameAt&&current.GameMode==GameMode
33 &&!NavigationSafety.UnhealthyHull(current)&&!NavigationSafety.HasExternalThreat(current)
34 &&current.Navigation.CurrentSystemAddress==OriginSystemAddress&&current.Navigation.JumpInProgress
35 &&current.Navigation.JumpTargetSystemAddress==TargetSystemAddress&&string.Equals(current.Navigation.JumpTargetSystem,TargetSystem,StringComparison.OrdinalIgnoreCase)
36 &&current.Navigation.JumpStartedAt==JumpStartedAt;
37 public static InputFreeJumpSnapshot? AfterSuccessfulRelease(PlayerState initial,PlayerState current,NavWaypoint actualTarget,
38 DateTimeOffset runJournalStartedAt,DateTimeOffset waitingSince)
39 {
40 if(string.IsNullOrWhiteSpace(initial.CommanderId)||initial.CurrentShipId is not >=0||initial.LoadGameAt is not {} loaded
41 ||current.Navigation.CurrentSystemAddress is not >0||current.Navigation.JumpStartedAt is not {} started||started<runJournalStartedAt||started<loaded
42 ||current.Navigation.JumpTargetSystemAddress!=actualTarget.SystemAddress||!string.Equals(current.Navigation.JumpTargetSystem,actualTarget.Name,StringComparison.OrdinalIgnoreCase))return null;
43 var snapshot=new InputFreeJumpSnapshot(initial.CommanderId,initial.CurrentShipId.Value,loaded,initial.GameMode,current.Navigation.CurrentSystemAddress.Value,
44 actualTarget.SystemAddress,actualTarget.Name,started,waitingSince,true);
45 return snapshot.Matches(current,waitingSince)?snapshot:null;
46 }
47 }
Modified XFEEDToolBox.Core/Navigation/NavigationRunner.cs +50 -6
@@ -41,11 +41,34 @@ public sealed class NavigationRunner(Func<PlayerState> player,Func<DateTimeOffse
41 41 private readonly Func<DateTimeOffset> time=clock??(()=>DateTimeOffset.UtcNow);
42 42 private readonly object gate=new();
43 43 private CancellationTokenSource? cancellation;
44 private InputFreeJumpSnapshot? inputFreeJump;
45 private FlightAutomationStopRecord? lastStop;
46 private string stopRunId="";
47 private CancellationToken parentCancellation;
48 public FlightAutomationStopRecord? LastStop{get{lock(gate)return lastStop;}}
49 public event Action<FlightAutomationStopRecord>? StopRecorded;
50 public InputFreeJumpSnapshot? AwaitingInputFreeJump{get{lock(gate)return inputFreeJump;}}
51 public bool IsAwaitingInputFreeJump(PlayerState current,DateTimeOffset now)
52 {lock(gate)return cancellation is {IsCancellationRequested:false}&&inputFreeJump?.Matches(current,now)==true;}
44 53 public NavigationRunState State { get; private set; }
45 54 public Exception? Failure {get;private set;}
46 55 public string LastMessage{get;private set;}="";
47 56 public event Action<NavigationProgress>? Progress;
48 public void Stop(){lock(gate)cancellation?.Cancel();}
57 private FlightAutomationStopRecord CaptureStopLocked(string source,bool manual=false)
58 {
59 if(lastStop is not null)return lastStop;
60 FlightStopSnapshot? snapshot=null;try{snapshot=FlightStopSnapshot.Capture(player());}catch{}
61 return lastStop=new(stopRunId,source,time(),null,FlightAutomationStage.Interstellar,parentCancellation.IsCancellationRequested,
62 source=="native-player-override"?"原生飞行帧确认玩家接管。":manual?"显式停止星际导航。":"父级取消星际导航。",null,snapshot){ManualVeto=manual,ManualVetoAt=manual?time():null};
63 }
64 private void PublishStop(FlightAutomationStopRecord record)
65 {if(StopRecorded is {} handlers)foreach(Action<FlightAutomationStopRecord> handler in handlers.GetInvocationList())try{handler(record);}catch{}}
66 public void Stop()
67 {
68 CancellationTokenSource? stop;FlightAutomationStopRecord? record=null;
69 lock(gate){inputFreeJump=null;stop=cancellation;if(stop is not null)record=CaptureStopLocked("runner-stop",manual:true);}
70 if(record is not null)PublishStop(record);try{stop?.Cancel();}catch(ObjectDisposedException){}
71 }
49 72 public async Task RunAsync(string destination,IFlightControlDriver driver,CancellationToken token=default,IFlightJourneyFailureClassifier? failureClassifier=null)
50 73 {
51 74 Failure=null;
@@ -53,8 +76,14 @@ public sealed class NavigationRunner(Func<PlayerState> player,Func<DateTimeOffse
53 76 lock(gate)
54 77 {
55 78 if(cancellation is not null)throw new InvalidOperationException("自动导航已经在运行。");
56 stop=CancellationTokenSource.CreateLinkedTokenSource(token);cancellation=stop;
79 stop=CancellationTokenSource.CreateLinkedTokenSource(token);cancellation=stop;inputFreeJump=null;lastStop=null;stopRunId=Guid.NewGuid().ToString("N");parentCancellation=token;
57 80 }
81 using var clearJumpOnCancellation=stop.Token.Register(()=>
82 {
83 FlightAutomationStopRecord? record=null;
84 lock(gate)if(ReferenceEquals(cancellation,stop)){inputFreeJump=null;record=CaptureStopLocked("parent-token");}
85 if(record is not null)PublishStop(record);
86 });
58 87 NavigationProgress? terminal=null;
59 88 var lastNext="—";
60 89 void Report(NavigationRunState state,string next,string message,FlightDecision? decision=null)
@@ -138,13 +167,19 @@ public sealed class NavigationRunner(Func<PlayerState> player,Func<DateTimeOffse
138 167 while(true)
139 168 {
140 169 stop.Token.ThrowIfCancellationRequested();var state=player();
170 lock(gate)if(inputFreeJump is {} remembered&&!remembered.Matches(state,time()))inputFreeJump=null;
141 171 if(NavigationSafety.UnhealthyHull(state))throw new InvalidOperationException("船体已失效,已停止自动导航。");
142 172 if(!state.InGame)throw new InvalidOperationException("游戏会话结束,已停止导航。");
143 173 if(NavigationSafety.HasExternalThreat(state))throw new FlightThreatPauseException();
144 174 if(!NavigationRouteProgress.SameItinerary(route,state.Navigation))
145 175 {
146 176 if(!ExpectedFinalClear(state.Navigation))throw new InvalidOperationException("游戏航线已变更,已停止导航。");
147 if(finalClearSince is null){await driver.ReleaseAsync();finalClearSince=time();}
177 if(finalClearSince is null)
178 {
179 finalClearSince=jumpWaitSince??time();await driver.ReleaseAsync();stop.Token.ThrowIfCancellationRequested();
180 var released=player();var witness=InputFreeJumpSnapshot.AfterSuccessfulRelease(initial,released,finalWaypoint,journalStartedAt,finalClearSince.Value);
181 lock(gate)if(ReferenceEquals(cancellation,stop)&&!stop.IsCancellationRequested)inputFreeJump=witness;
182 }
148 183 if(!state.Navigation.JumpInProgress&&state.Navigation.CurrentSystemAddress==finalWaypoint.SystemAddress
149 184 &&state.Navigation.LastJumpAt>=state.Navigation.JumpStartedAt)
150 185 {
@@ -161,7 +196,12 @@ public sealed class NavigationRunner(Func<PlayerState> player,Func<DateTimeOffse
161 196 &&state.Navigation.JumpTargetSystem.Equals(remaining[0].Name,StringComparison.OrdinalIgnoreCase)
162 197 &&state.Navigation.JumpStartedAt is {} activeJump&&activeJump>=journalStartedAt&&activeJump<=GameJournalClock.Now(state,time()).AddSeconds(JournalFutureAllowanceSeconds))
163 198 {
164 if(jumpWaitSince is null){await driver.ReleaseAsync();jumpWaitSince=time();}
199 if(jumpWaitSince is null)
200 {
201 jumpWaitSince=time();await driver.ReleaseAsync();stop.Token.ThrowIfCancellationRequested();
202 var released=player();var witness=InputFreeJumpSnapshot.AfterSuccessfulRelease(initial,released,remaining[0],journalStartedAt,jumpWaitSince.Value);
203 lock(gate)if(ReferenceEquals(cancellation,stop)&&!stop.IsCancellationRequested)inputFreeJump=witness;
204 }
165 205 if(time()-jumpWaitSince>TimeSpan.FromSeconds(75))throw new InvalidOperationException("跃迁抵达事件超时,已释放自动控制。");
166 206 Report(NavigationRunState.Running,remaining[0].Name,"跃迁已开始,等待实际抵达;加载期间不发送飞行输入。");
167 207 await Task.Delay(100,stop.Token);continue;
@@ -209,7 +249,11 @@ public sealed class NavigationRunner(Func<PlayerState> player,Func<DateTimeOffse
209 249 {await PauseForTelemetry(remaining[0].Name,new FlightTelemetryUnavailableException("重新确认下一跳。",recovery:FlightRecovery.ReselectTarget));continue;}
210 250 // No enabled scoop cannot turn a measured shortfall into fuel.
211 251 // Return the typed task to the journey owner instead of waiting forever.
212 if(frame.PlayerOverride){Report(NavigationRunState.Stopped,remaining[0].Name,"玩家已接管,自动导航停止。");break;}
252 if(frame.PlayerOverride)
253 {
254 FlightAutomationStopRecord record;lock(gate){inputFreeJump=null;record=CaptureStopLocked("native-player-override",manual:true);}PublishStop(record);
255 Report(NavigationRunState.Stopped,remaining[0].Name,"玩家已接管,自动导航停止。");break;
256 }
213 257 if(frame.GeometryVerified&&now-frame.ObservedAt<=TimeSpan.FromMilliseconds(300)&&frame.ObservedAt<=now.AddMilliseconds(100)
214 258 &&NavigationFuelResupply.Required(frame) is {} shortage)throw shortage;
215 259 var decision=navigator.Decide(frame,remaining[0],observation.Thermal,now);
@@ -251,7 +295,7 @@ public sealed class NavigationRunner(Func<PlayerState> player,Func<DateTimeOffse
251 295 catch(Exception e){Failure=Failure is null?e:new AggregateException(Failure,e);terminal=new(NavigationRunState.Failed,destination,"—","释放自动控制失败:"+e.Message);}
252 296 finally
253 297 {
254 lock(gate){cancellation=null;}stop.Dispose();
298 lock(gate){inputFreeJump=null;cancellation=null;}stop.Dispose();
255 299 if(terminal is {} final){State=final.State;LastMessage=final.Message;Progress?.Invoke(final);}
256 300 }
257 301 }
Modified XFEEDToolBox.Core/Services/ToolboxRuntime.cs +19 -1
@@ -72,6 +72,23 @@ public sealed class ToolboxRuntime : IAsyncDisposable
72 72 Spansh = new(Markets, http);
73 73 Runner = new(() => Player, (market, key) => Markets.Search(key, market, 0, 1000).FirstOrDefault(x => x.Quote.Commodity == key)?.Quote, Path.Combine(dataDirectory,"trade-ledgers"));
74 74 FlightAutomation=new(()=>Player);
75 FlightAutomation.StopRecorded+=record=>
76 {
77 // The in-memory cause exists before cancellation. Persist on the
78 // writer so disk I/O cannot delay an emergency input release.
79 writes.Writer.TryWrite(()=>
80 {
81 ReportDiagnostic("flight.stop:"+record.RunId,"驾驶停止",
82 record.ManualVeto?"已按停止请求释放自动驾驶。":record.Cause is {} cause?"驾驶暂停:"+cause.Message:"自动驾驶已停止。",
83 System.Text.Json.JsonSerializer.Serialize(new
84 {
85 stop=record with{Cause=null},causeType=record.Cause?.GetType().FullName,causeMessage=record.Cause?.Message
86 },ToolboxSettings.JsonOptions),record.ManualVeto?ActivitySeverity.Status:
87 record.Cause is {} failure&&XFEEDToolBox.Core.Navigation.NavigationRecovery.HasInvalidDataCause(failure)?ActivitySeverity.Error:
88 record.Cause is IOException?ActivitySeverity.Status:ActivitySeverity.Warning);
89 return Task.CompletedTask;
90 });
91 };
75 92 Runner.Progress += p => { Log("执行", p.Message); RunChanged?.Invoke(p); };
76 93 }
77 94
@@ -282,8 +299,9 @@ public sealed class ToolboxRuntime : IAsyncDisposable
282 299 var state=status.TryGetProperty("sample",out var sample)?Field(sample,"state"):"";
283 300 var stopped=status.TryGetProperty("lastOriginalStop",out var stop)?Field(stop,"observedAtUnixMilliseconds"):"";
284 301 var application=status.TryGetProperty("applicationControl",out var control)?control:default;
302 var diagnostic=status.TryGetProperty("diagnostic",out var details)?details:default;
285 303 var key=string.Join(':',Field(status,"processId"),Field(status,"service"),Field(status,"watchEntityId"),Field(status,"originalCommandSerial"),stopped,Field(status,"issue"),state,
286 Field(application,"detail"),Field(application,"policyReason"));
304 Field(application,"detail"),Field(application,"policyReason"),Field(status,"targetSystemAddress"),Field(diagnostic,"generation"));
287 305 lock(scoDiagnosticGate){if(key==lastScoDiagnostic)return;lastScoDiagnostic=key;}
288 306 var line=System.Text.Json.JsonSerializer.Serialize(new{at=DateTimeOffset.UtcNow,status=status.Clone()})+Environment.NewLine;
289 307 writes.Writer.TryWrite(()=>
Added XFEEDToolBox.Core/Session/NativeFlightHealthGapPolicy.cs +47 -0
@@ -0,0 +1,47 @@
1 using XFEEDToolBox.Core.Navigation;
2
3 namespace XFEEDToolBox.Core.Session;
4
5 /// <summary>Read-only monitoring decisions never extend a health sample or authorize flight input.</summary>
6 public sealed record NativeFlightHealthGapDecision(bool PauseMonitoring,bool SafetyStop,string Phase,string Issue,
7 DateTimeOffset? UnavailableSince,TimeSpan UnavailableFor,bool ExpectedInputFreeJumpLoading);
8
9 public sealed record NativeFlightProtectionDiagnostic(DateTimeOffset At,int ProcessId,long StartedUtcTicks,string GameMode,
10 string Phase,string Issue,long? LastHealthSequence,ulong? LastHealthEntity,DateTimeOffset? LastHealthAt,double? LastHealthAgeMilliseconds,
11 DateTimeOffset? UnavailableSince,double UnavailableMilliseconds,bool ExpectedInputFreeJumpLoading,bool MonitoringOnly,
12 string? ExceptionType,string? ExceptionMessage);
13
14 /// <summary>The caller supplies positive, released-input jump evidence with its original 75-second deadline.</summary>
15 public sealed class NativeFlightHealthGapPolicy
16 {
17 public static readonly TimeSpan InputFreshness=TimeSpan.FromMilliseconds(500);
18 public static readonly TimeSpan OrdinaryGapLimit=TimeSpan.FromSeconds(30);
19 // Defence in depth if a caller ever supplies an incorrect always-true
20 // loading predicate. The caller's original jump deadline remains stricter.
21 public static readonly TimeSpan MaximumReadOnlyGap=TimeSpan.FromSeconds(75);
22 private long unavailableUtcTicks;
23 public DateTimeOffset? UnavailableSince
24 {get{var ticks=Interlocked.Read(ref unavailableUtcTicks);return ticks>0?new DateTimeOffset(ticks,TimeSpan.Zero):null;}}
25 public void Recovered()=>Interlocked.Exchange(ref unavailableUtcTicks,0);
26 public static bool InputSampleFresh(DateTimeOffset? sampledAt,DateTimeOffset now)
27 =>sampledAt is {} at&&at<=now&&now-at<=InputFreshness;
28 public static bool RecoverableGap(Exception error,CancellationToken token)
29 {
30 if(token.IsCancellationRequested||NavigationRecovery.HasInvalidDataCause(error)||FlightSafetyPauseException.Present(error))return false;
31 if(error is AggregateException aggregate)return aggregate.InnerExceptions.Count>0&&aggregate.InnerExceptions.All(inner=>RecoverableGap(inner,token));
32 if(error is FlightTelemetryUnavailableException or IOException or TimeoutException or OperationCanceledException)
33 return error.InnerException is not {} inner||RecoverableGap(inner,token);
34 return error is InvalidOperationException&&error.Message is "Native main-ship health unavailable or stale" or "Native player telemetry unavailable or stale";
35 }
36 public NativeFlightHealthGapDecision Observe(Exception error,DateTimeOffset now,bool expectedInputFreeJumpLoading,CancellationToken token=default)
37 {
38 token.ThrowIfCancellationRequested();Interlocked.CompareExchange(ref unavailableUtcTicks,now.UtcTicks,0);
39 var elapsed=now-UnavailableSince.Value;
40 if(FlightSafetyPauseException.Present(error))return new(false,true,"safety-stop",error.Message,UnavailableSince,elapsed,false);
41 if(elapsed<TimeSpan.Zero||!RecoverableGap(error,token))return new(true,false,"monitoring-paused",error.Message,UnavailableSince,elapsed,false);
42 if(elapsed<OrdinaryGapLimit)return new(false,false,"health-gap",error.Message,UnavailableSince,elapsed,expectedInputFreeJumpLoading);
43 if(expectedInputFreeJumpLoading&&elapsed<MaximumReadOnlyGap)
44 return new(false,false,"input-free-jump-loading",error.Message,UnavailableSince,elapsed,true);
45 return new(true,false,"monitoring-paused",error.Message,UnavailableSince,elapsed,expectedInputFreeJumpLoading);
46 }
47 }
Modified XFEEDToolBox.Core/Session/NativeFlightTestProtection.cs +62 -17
@@ -19,6 +19,13 @@ public sealed class NativeFlightTestProtection:IAsyncDisposable
19 19 private readonly bool automaticExit;
20 20 private readonly bool exitForFailedHandoff;
21 21 private readonly Action? externalProtectionCheck;
22 private readonly Func<bool>? expectedInputFreeJumpLoading;
23 private readonly Action<Exception>? pauseAutomation;
24 private readonly Action<NativeFlightProtectionDiagnostic>? diagnostics;
25 private readonly NativeFlightHealthGapPolicy healthGap=new();
26 private readonly object diagnosticGate=new();
27 private string? lastDiagnosticPhase;
28 private int monitoringPauseReported;
22 29 private readonly IGameSessionDriver sessionDriver;
23 30 private bool independentHealth;
24 31 private Task? polling;
@@ -26,17 +33,51 @@ public sealed class NativeFlightTestProtection:IAsyncDisposable
26 33 private int damagePause;
27 34 private long lastHealthTicks,lastSequence;
28 35 private ulong entity;
29 public ShipHealthSample? LastHealth{get;private set;}
36 private ShipHealthSample? lastHealth;
37 public ShipHealthSample? LastHealth=>Volatile.Read(ref lastHealth);
30 38 public string? LastIssue{get;private set;}
31 39 public bool Stopped=>Volatile.Read(ref unsafeState)!=0||Volatile.Read(ref damagePause)!=0;
32 public bool Fresh=>!Stopped&&Volatile.Read(ref damagePause)==0&&!monitor.TestingBlocked&&DateTimeOffset.UtcNow.UtcTicks-Interlocked.Read(ref lastHealthTicks)<=TimeSpan.FromMilliseconds(500).Ticks;
33 private NativeFlightTestProtection(int pid,GameSessionTarget target,IGameSessionDriver sessions,Func<PlayerState> player,Action stopAutomation,ShipProtectionMonitor? sharedMonitor,bool automaticExit,bool exitForFailedHandoff,Action? externalProtectionCheck)
40 private bool HealthSampleFresh(DateTimeOffset now)
41 {
42 var ticks=Interlocked.Read(ref lastHealthTicks);
43 return NativeFlightHealthGapPolicy.InputSampleFresh(ticks>0?new DateTimeOffset(ticks,TimeSpan.Zero):null,now);
44 }
45 public bool Fresh=>!Stopped&&Volatile.Read(ref damagePause)==0&&!monitor.TestingBlocked&&HealthSampleFresh(DateTimeOffset.UtcNow);
46 private NativeFlightTestProtection(int pid,GameSessionTarget target,IGameSessionDriver sessions,Func<PlayerState> player,Action stopAutomation,ShipProtectionMonitor? sharedMonitor,bool automaticExit,bool exitForFailedHandoff,Action? externalProtectionCheck,
47 Func<bool>? expectedInputFreeJumpLoading,Action<Exception>? pauseAutomation,Action<NativeFlightProtectionDiagnostic>? diagnostics)
34 48 {
35 49 pipe=new(pid);this.target=target;this.player=player;this.stopAutomation=stopAutomation;sessionDriver=sessions;this.automaticExit=automaticExit;this.exitForFailedHandoff=exitForFailedHandoff;this.externalProtectionCheck=externalProtectionCheck;
50 this.expectedInputFreeJumpLoading=expectedInputFreeJumpLoading;this.pauseAutomation=pauseAutomation;this.diagnostics=diagnostics;
36 51 ownsMonitor=sharedMonitor is null;
37 52 monitor=sharedMonitor??new(sessions,player,StopAutomation,automaticExit);
38 53 }
39 private void StopAutomation(){try{stopAutomation();}catch{/* A stopped worker cannot disarm health monitoring. */}}
54 private void Diagnose(string phase,string issue,Exception? error=null,bool expectedLoading=false)
55 {
56 lock(diagnosticGate){if(lastDiagnosticPhase==phase)return;lastDiagnosticPhase=phase;}
57 if(diagnostics is null)return;
58 try
59 {
60 var now=DateTimeOffset.UtcNow;var health=LastHealth;var unavailable=healthGap.UnavailableSince;
61 diagnostics(new(now,target.ProcessId,target.StartedUtcTicks,player().GameMode,phase,issue,
62 health?.Sequence,health?.EntityId,health?.At,health is null?null:(now-health.At).TotalMilliseconds,
63 unavailable,unavailable is {} began?(now-began).TotalMilliseconds:0,expectedLoading,
64 phase is "health-gap" or "input-free-jump-loading",error?.GetType().FullName,error?.Message));
65 }
66 catch{/* Evidence delivery cannot grant input or prevent a required pause. */}
67 }
68 private void StopAutomation()
69 {
70 Diagnose("safety-stop",monitor.LastTrigger?.Message??LastIssue??"原飞船安全保护停止自动控制。");
71 try{stopAutomation();}catch{/* A stopped worker cannot disarm health monitoring. */}
72 }
73 private void PauseMonitoring(Exception error,bool expectedLoading=false)
74 {
75 LastIssue=error.Message;Interlocked.Exchange(ref unsafeState,1);
76 if(Interlocked.Exchange(ref monitoringPauseReported,1)!=0)return;
77 Diagnose("monitoring-paused",error.Message,error,expectedLoading);
78 try{if(pauseAutomation is not null)pauseAutomation(error);else stopAutomation();}
79 catch{try{stopAutomation();}catch{/* The protected state is already latched. */}}
80 }
40 81 public static async Task ExitForFailedHandoffAsync(int pid)=>await HandleFailedHandoffAsync(pid);
41 82 public static async Task<bool> HandleFailedHandoffAsync(int pid)
42 83 {
@@ -51,7 +92,8 @@ public sealed class NativeFlightTestProtection:IAsyncDisposable
51 92 await new GameSessionController(protection.sessionDriver,()=>false).EmergencyExitAsync(protection.target,CancellationToken.None);
52 93 return true;
53 94 }
54 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)
95 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,
96 Func<bool>? expectedInputFreeJumpLoading=null,Action<Exception>? pauseAutomation=null,Action<NativeFlightProtectionDiagnostic>? diagnostics=null)
55 97 {
56 98 if(Volatile.Read(ref active) is not null)throw new InvalidOperationException("已有飞行测试保护正在运行。");
57 99 var pipe=new NavigationPipeClient(pid);var hello=await pipe.CallAsync(new{op="describe"},token);
@@ -64,7 +106,8 @@ public sealed class NativeFlightTestProtection:IAsyncDisposable
64 106 if(sharedMonitor is not null&&sharedMonitor.BoundTarget!=target)throw new InvalidOperationException("连续血量监测与受保护游戏实例不一致。");
65 107 if(sharedMonitor is not null&&sharedMonitor.AutomaticExitEnabled!=automaticExit)throw new InvalidOperationException("连续血量监测的退出模式不一致。");
66 108 externalProtectionCheck?.Invoke();
67 var protection=new NativeFlightTestProtection(pid,target,sessions,player,stopAutomation,sharedMonitor,automaticExit,exitForFailedHandoff,externalProtectionCheck);
109 var protection=new NativeFlightTestProtection(pid,target,sessions,player,stopAutomation,sharedMonitor,automaticExit,exitForFailedHandoff,externalProtectionCheck,
110 expectedInputFreeJumpLoading,pauseAutomation,diagnostics);
68 111 protection.independentHealth=hello.TryGetProperty("independentShipHealth",out var independent)&&independent.ValueKind==JsonValueKind.True;
69 112 try
70 113 {
@@ -88,7 +131,7 @@ public sealed class NativeFlightTestProtection:IAsyncDisposable
88 131 throw new InvalidOperationException("飞船保护已停止测试,禁止继续发送飞行输入。");
89 132 if(protection.monitor.TestingBlocked)throw new FlightHealthPauseException();
90 133 protection.externalProtectionCheck?.Invoke();
91 if(DateTimeOffset.UtcNow.UtcTicks-Interlocked.Read(ref protection.lastHealthTicks)>TimeSpan.FromMilliseconds(500).Ticks)
134 if(!protection.HealthSampleFresh(DateTimeOffset.UtcNow))
92 135 throw new FlightTelemetryUnavailableException("正在恢复连续血量监测,恢复后继续驾驶。");
93 136 }
94 137 public void ObserveJournal(ObservedJournalEvent observed)=>monitor.Observe(observed,player());
@@ -118,12 +161,11 @@ public sealed class NativeFlightTestProtection:IAsyncDisposable
118 161 // Multiple IPC reads can observe one physics frame. Retain its actual
119 162 // timestamp; a duplicate neither refreshes the input lease nor stops protection.
120 163 if(sample.Sequence==lastSequence)return sample;
121 entity=sample.EntityId;lastSequence=sample.Sequence;LastHealth=sample;Interlocked.Exchange(ref lastHealthTicks,sample.At.UtcTicks);
164 entity=sample.EntityId;lastSequence=sample.Sequence;Volatile.Write(ref lastHealth,sample);Interlocked.Exchange(ref lastHealthTicks,sample.At.UtcTicks);
122 165 return sample;
123 166 }
124 167 private async Task PollAsync()
125 168 {
126 DateTimeOffset? unavailableSince=null;
127 169 try
128 170 {
129 171 while(!stop.IsCancellationRequested)
@@ -132,15 +174,18 @@ public sealed class NativeFlightTestProtection:IAsyncDisposable
132 174 try
133 175 {
134 176 var health=await ReadAsync(stop.Token);monitor.Observe(health,DateTimeOffset.UtcNow);
135 unavailableSince=null;if(!Stopped)LastIssue=null;
177 if(healthGap.UnavailableSince is not null)
178 Diagnose(Stopped?"health-restored-awaiting-rearm":"health-recovered","已重新读取实际新鲜船体与护盾样本。");
179 healthGap.Recovered();if(!Stopped)LastIssue=null;
136 180 }
137 catch(Exception error)when(error is IOException or InvalidOperationException or OperationCanceledException&& !stop.IsCancellationRequested)
181 catch(Exception error)when(!stop.IsCancellationRequested)
138 182 {
139 LastIssue=error.Message;unavailableSince??=DateTimeOffset.UtcNow;
140 // Input is already barred as soon as the last real sample
141 // expires. Brief IPC/frame stalls may recover without disarming.
142 if(DateTimeOffset.UtcNow-unavailableSince>=TimeSpan.FromSeconds(30)&&!Stopped)
143 {Interlocked.Exchange(ref unsafeState,1);StopAutomation();}
183 LastIssue=error.Message;
184 var expectedLoading=NativeFlightHealthGapPolicy.RecoverableGap(error,stop.Token)&&expectedInputFreeJumpLoading?.Invoke()==true;
185 var decision=healthGap.Observe(error,DateTimeOffset.UtcNow,expectedLoading,stop.Token);
186 if(decision.SafetyStop){Interlocked.Exchange(ref unsafeState,1);StopAutomation();}
187 else if(decision.PauseMonitoring)PauseMonitoring(error,expectedLoading);
188 else Diagnose(decision.Phase,decision.Issue,error,expectedLoading);
144 189 }
145 190 if(monitor.EmergencyActive){Interlocked.Exchange(ref unsafeState,1);StopAutomation();break;}
146 191 if(monitor.TestingBlocked)
@@ -153,7 +198,7 @@ public sealed class NativeFlightTestProtection:IAsyncDisposable
153 198 }
154 199 }
155 200 catch(OperationCanceledException)when(stop.IsCancellationRequested){}
156 catch(Exception error){LastIssue=error.Message;Interlocked.Exchange(ref unsafeState,1);StopAutomation();}
201 catch(Exception error){PauseMonitoring(error);}
157 202 }
158 203 public async ValueTask DisposeAsync()
159 204 {
Modified XFEEDToolBox.Core/Session/ShipProtectionMonitor.cs +5 -1
@@ -25,6 +25,9 @@ public sealed class ShipProtectionMonitor(IGameSessionDriver driver,Func<PlayerS
25 25 public bool ExitOnInterdiction{get;init;}=true;
26 26 public bool ContinuousHealthReady=>nativeProtection?.Fresh==true;
27 27 public bool AutomaticExitEnabled=>automaticExit;
28 public Func<bool>? ExpectedInputFreeJumpLoading{get;set;}
29 public Action<Exception>? PauseAutomationForMonitoring{get;set;}
30 public Action<NativeFlightProtectionDiagnostic>? NativeProtectionDiagnostics{get;set;}
28 31 public event Action<ShipProtectionProgress>? Progress;
29 32 public event Action<SessionReentryEvent>? Reentry;
30 33 public bool TestingBlocked{get{lock(gate)return guard.TestingBlocked;}}
@@ -53,7 +56,8 @@ public sealed class ShipProtectionMonitor(IGameSessionDriver driver,Func<PlayerS
53 56 RequireHealthyShip();var expected=BoundTarget??throw new InvalidOperationException("飞船保护尚未绑定游戏实例。");
54 57 if(nativeProtection is {Stopped:false})return;
55 58 if(nativeProtection is {} previous){nativeProtection=null;await previous.DisposeAsync();}
56 nativeProtection=await NativeFlightTestProtection.ArmAsync(expected.ProcessId,gameHash,driver,player,StopAutomation,token,this,automaticExit,exitForFailedHandoff);
59 nativeProtection=await NativeFlightTestProtection.ArmAsync(expected.ProcessId,gameHash,driver,player,StopAutomation,token,this,automaticExit,exitForFailedHandoff,
60 expectedInputFreeJumpLoading:ExpectedInputFreeJumpLoading,pauseAutomation:PauseAutomationForMonitoring,diagnostics:NativeProtectionDiagnostics);
57 61 Report(false,"连续保护中 · 被拦截立即大退;受损后破盾或低血量立即大退");
58 62 }
59 63 finally{nativeGate.Release();}
Modified XFEEDToolBox.Test/FlightAutomationCoordinatorTests.cs +83 -1
@@ -35,6 +35,7 @@ public sealed class FlightAutomationCoordinatorTests
35 35 internal bool Arrive=true,AutoLand=true;
36 36 internal bool ManualOverride;
37 37 internal Exception? LocalInspectionFailure,ClassifiedFailure;
38 internal Exception? GalaxyReleaseFailure;
38 39 internal int Classifications;
39 40 public Task<Exception> ClassifyFailureAsync(Exception failure,CancellationToken token)
40 41 {token.ThrowIfCancellationRequested();Calls.Add("classify");Classifications++;return Task.FromResult(ClassifiedFailure??failure);}
@@ -64,7 +65,7 @@ public sealed class FlightAutomationCoordinatorTests
64 65 public virtual Task SelectTargetAsync(NavWaypoint next,CancellationToken token)=>throw new InvalidOperationException("Unexpected live jump input in fixture");
65 66 public virtual Task<FlightObservation> ReadAsync(CancellationToken token)=>throw new InvalidOperationException("Unexpected live flight read in fixture");
66 67 public Task ApplyAsync(FlightDecision decision,long sequence,NavWaypoint next,DateTimeOffset expires,CancellationToken token)=>throw new InvalidOperationException("Unexpected live jump input in fixture");
67 public Task ReleaseAsync(){Calls.Add("release-galaxy");return Task.CompletedTask;}
68 public virtual Task ReleaseAsync(){Calls.Add("release-galaxy");if(GalaxyReleaseFailure is {} failure)throw failure;return Task.CompletedTask;}
68 69 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"));}
69 70 private SystemNavigationTarget? target;
70 71 public Task SelectAsync(SystemNavigationTarget selected,CancellationToken token){Calls.Add("select");target=selected;return Task.CompletedTask;}
@@ -455,4 +456,85 @@ public sealed class FlightAutomationCoordinatorTests
455 456 var driver=new Journey{AutoLand=false};var result=await new FlightAutomationCoordinator(()=>driver.Player).TravelToStationAsync(Destination,driver);
456 457 Check.Equal(NavigationRunState.Failed,result.State);Check.That(!driver.Player.Docked&&result.Failure!.Message.Contains("实际停靠"));
457 458 }
459 [Test]public async Task MonitoringCancellationPreservesTheOriginalInvalidDataSafetyOrTransportCauseAndRecordsBeforeCancel()
460 {
461 foreach(var cause in new Exception[]{new InvalidDataException("identity observation unavailable"),new FlightHealthPauseException(),new IOException("health observation transport")})
462 {
463 var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);CancellationToken activeToken=default;var beforeCancel=false;
464 flights.StopRecorded+=record=>{if(record.Source=="monitoring-pause")beforeCancel=!activeToken.IsCancellationRequested;};
465 driver.Departure=token=>{activeToken=token;flights.PauseForMonitoring(cause);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
466 var result=await flights.TravelToStationAsync(Destination,driver);Check.Equal(NavigationRunState.Failed,result.State);Check.That(ReferenceEquals(cause,result.Failure)&&beforeCancel);
467 var diagnostic=flights.LastStop!;Check.Equal("monitoring-pause",diagnostic.Source);Check.That(!diagnostic.ManualVeto&&!diagnostic.ParentTokenCancelled&&ReferenceEquals(cause,diagnostic.Cause));
468 Check.Equal(FlightAutomationEntry.Trade,diagnostic.Entry!.Value);Check.Equal(FlightAutomationStage.Departing,diagnostic.Stage);Check.Equal(7L,diagnostic.Snapshot!.ShipId!.Value);
469 Check.That(!flights.IsActive&&flights.LastStop==diagnostic);
470 }
471 }
472 [Test]public async Task ManualVetoKeepsTheFirstMonitoringEvidenceButNeverEntersRecovery()
473 {
474 var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var first=new InvalidDataException("first monitoring failure");
475 driver.Departure=token=>{flights.PauseForMonitoring(first);flights.PauseForMonitoring(new IOException("later cleanup"));flights.Stop();token.ThrowIfCancellationRequested();return Task.CompletedTask;};
476 var result=await flights.TravelToStationAsync(Destination,driver);Check.Equal(NavigationRunState.Stopped,result.State);Check.That(result.Failure is null);
477 var record=flights.LastStop!;Check.Equal("monitoring-pause",record.Source);Check.That(record.ManualVeto&&record.ManualVetoAt is not null&&ReferenceEquals(first,record.Cause));
478 }
479 [Test]public async Task AMonitoringCauseCannotBeReplacedByTheDriversLaterReleaseFailure()
480 {
481 var driver=new Journey{GalaxyReleaseFailure=new IOException("cleanup release failed")};var flights=new FlightAutomationCoordinator(()=>driver.Player);var first=new InvalidDataException("first source loss");
482 driver.Departure=token=>{flights.PauseForMonitoring(first);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
483 var result=await flights.TravelToStationAsync(Destination,driver);Check.Equal(NavigationRunState.Failed,result.State);Check.That(ReferenceEquals(first,result.Failure)&&ReferenceEquals(first,flights.LastStop!.Cause));
484 }
485 [Test]public async Task SeparateJourneysCannotInheritAnotherRunsStopCauseAndEachCompletedStopRemainsQueryable()
486 {
487 var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var first=new IOException("first run");var second=new InvalidDataException("second run");
488 driver.Departure=token=>{flights.PauseForMonitoring(first);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
489 Check.That(ReferenceEquals(first,(await flights.TravelToStationAsync(Destination,driver)).Failure));var firstRecord=flights.LastStop!;
490 driver.Departure=token=>{flights.PauseForMonitoring(second);token.ThrowIfCancellationRequested();return Task.CompletedTask;};
491 Check.That(ReferenceEquals(second,(await flights.TravelToStationAsync(Destination,driver)).Failure));var secondRecord=flights.LastStop!;
492 Check.That(firstRecord.RunId!=secondRecord.RunId&&ReferenceEquals(first,firstRecord.Cause)&&ReferenceEquals(second,secondRecord.Cause));
493 flights.PauseForMonitoring(new IOException("after completion"));Check.That(ReferenceEquals(secondRecord,flights.LastStop));
494 }
495 [Test]public async Task AParentCancellationVetoDoesNotEraseTheRecordedMonitoringCause()
496 {
497 using var parent=new CancellationTokenSource();var driver=new Journey();var flights=new FlightAutomationCoordinator(()=>driver.Player);var first=new IOException("monitor before parent");
498 driver.Departure=token=>{flights.PauseForMonitoring(first);parent.Cancel();token.ThrowIfCancellationRequested();return Task.CompletedTask;};
499 var result=await flights.TravelToStationAsync(Destination,driver,parent.Token);Check.Equal(NavigationRunState.Stopped,result.State);Check.That(result.Failure is null);
500 Check.That(flights.LastStop!.ParentTokenCancelled&&ReferenceEquals(first,flights.LastStop.Cause));
501 }
502 private sealed class LoadingJourney(Func<DateTimeOffset> time,bool failRelease=false):Journey
503 {
504 internal readonly TaskCompletionSource ReleaseEntered=new(TaskCreationOptions.RunContinuationsAsynchronously);
505 internal readonly TaskCompletionSource AllowRelease=new(TaskCreationOptions.RunContinuationsAsynchronously);
506 private int releaseCount;
507 public override Task<FlightDriverCapabilities> PreflightAsync(CancellationToken token)
508 {
509 Arrive=false;Player=Player with{Navigation=Player.Navigation with{JumpInProgress=true,JumpTargetSystemAddress=2,JumpTargetSystem="System 2",JumpStartedAt=time()}};
510 return base.PreflightAsync(token);
511 }
512 public override async Task ReleaseAsync()
513 {
514 Calls.Add("release-galaxy");if(Interlocked.Increment(ref releaseCount)!=1)return;
515 ReleaseEntered.TrySetResult();await AllowRelease.Task;if(failRelease)throw new IOException("original release was not acknowledged");
516 }
517 }
518 [Test]public async Task InputFreeLoadingOnlyQualifiesAfterSuccessfulReleaseAndForTheExactJumpWithinItsOriginalDeadline()
519 {
520 var now=DateTimeOffset.UtcNow;var driver=new LoadingJourney(()=>now);var flights=new FlightAutomationCoordinator(()=>driver.Player,()=>now);
521 var waiting=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
522 flights.Interstellar.Progress+=p=>{if(p.Message.Contains("跃迁已开始"))waiting.TrySetResult();};
523 var run=flights.TravelToStationAsync(Destination,driver);await driver.ReleaseEntered.Task.WaitAsync(TimeSpan.FromSeconds(2));
524 Check.That(!flights.IsAwaitingInputFreeJump(driver.Player,now));driver.AllowRelease.SetResult();await waiting.Task.WaitAsync(TimeSpan.FromSeconds(2));
525 Check.That(flights.IsAwaitingInputFreeJump(driver.Player,now));Check.That(flights.IsAwaitingInputFreeJump(driver.Player,now.AddSeconds(75)));
526 Check.That(!flights.IsAwaitingInputFreeJump(driver.Player,now.AddSeconds(75).AddMilliseconds(1)));
527 foreach(var changed in new[]{driver.Player with{CommanderId="other"},driver.Player with{CurrentShipId=8},driver.Player with{LoadGameAt=now},
528 driver.Player with{Navigation=driver.Player.Navigation with{JumpInProgress=false}},driver.Player with{Navigation=driver.Player.Navigation with{JumpTargetSystemAddress=3}},
529 driver.Player with{Navigation=driver.Player.Navigation with{JumpStartedAt=now.AddSeconds(1)}}})
530 Check.That(!flights.IsAwaitingInputFreeJump(changed,now));
531 flights.Stop();Check.That(!flights.IsAwaitingInputFreeJump(driver.Player,now));Check.Equal(NavigationRunState.Stopped,(await run.WaitAsync(TimeSpan.FromSeconds(2))).State);
532 }
533 [Test]public async Task AFailedOriginalReleaseNeverAuthorizesTheLoadingException()
534 {
535 var now=DateTimeOffset.UtcNow;var driver=new LoadingJourney(()=>now,failRelease:true);var flights=new FlightAutomationCoordinator(()=>driver.Player,()=>now);
536 var run=flights.TravelToStationAsync(Destination,driver);await driver.ReleaseEntered.Task.WaitAsync(TimeSpan.FromSeconds(2));
537 Check.That(!flights.IsAwaitingInputFreeJump(driver.Player,now));driver.AllowRelease.SetResult();var result=await run.WaitAsync(TimeSpan.FromSeconds(2));
538 Check.Equal(NavigationRunState.Failed,result.State);Check.That(!flights.IsAwaitingInputFreeJump(driver.Player,now)&&flights.Interstellar.AwaitingInputFreeJump is null);
539 }
458 540 }
Added XFEEDToolBox.Test/FlightDiagnosticPersistenceTests.cs +30 -0
@@ -0,0 +1,30 @@
1 using System.Text.Json;
2 using XFEEDToolBox.Core.Services;
3 using XFEExtension.NetCore.XUnit.Attributes;
4
5 namespace XFEEDToolBox.Test;
6
7 [TestFixture]
8 public sealed class FlightDiagnosticPersistenceTests
9 {
10 [Test]public async Task IdenticalAcknowledgementsDeduplicateButDifferentTargetsAndGenerationsPersist()
11 {
12 using var temporary=new TemporaryDirectory();
13 await using(var runtime=new ToolboxRuntime(temporary.Path))
14 {
15 await runtime.InitializeAsync(offline:true);
16 static JsonElement Ack(long target,long generation)=>JsonSerializer.SerializeToElement(new
17 {
18 processId=42,service="InterstellarTargetSelection",issue="acknowledged",targetSystemAddress=target,
19 sample=new{state="acknowledged"},diagnostic=new{generation,selected=true}
20 });
21 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);
22 }
23 var rows=File.ReadAllLines(Path.Combine(temporary.Path,"logs","sco-diagnostics.jsonl"))
24 .Select(line=>{using var document=JsonDocument.Parse(line);return document.RootElement.GetProperty("status").Clone();}).ToArray();
25 Check.Equal(3,rows.Length);
26 Check.Equal(1L,rows[0].GetProperty("targetSystemAddress").GetInt64());
27 Check.Equal(2L,rows[1].GetProperty("targetSystemAddress").GetInt64());
28 Check.Equal(6L,rows[2].GetProperty("diagnostic").GetProperty("generation").GetInt64());
29 }
30 }
Added XFEEDToolBox.Test/NativeFlightHealthGapPolicyTests.cs +75 -0
@@ -0,0 +1,75 @@
1 using XFEEDToolBox.Core.Navigation;
2 using XFEEDToolBox.Core.Session;
3 using XFEExtension.NetCore.XUnit.Attributes;
4
5 namespace XFEEDToolBox.Test;
6
7 [TestFixture]
8 public sealed class NativeFlightHealthGapPolicyTests
9 {
10 private static readonly DateTimeOffset Now=DateTimeOffset.Parse("2026-10-27T07:41:46+08:00");
11 [Test]public void AReleasedInputJumpMayOutlastThirtySecondsButNeverExceedsItsBoundedReadOnlyWindow()
12 {
13 var gap=new NativeFlightHealthGapPolicy();var error=new InvalidOperationException("Native main-ship health unavailable or stale");
14 Check.That(!gap.Observe(error,Now,false).PauseMonitoring);
15 Check.Equal("health-gap",gap.Observe(error,Now.AddSeconds(29),true).Phase);
16 var loading=gap.Observe(error,Now.AddSeconds(30),true);
17 Check.That(!loading.PauseMonitoring&&!loading.SafetyStop&&loading.ExpectedInputFreeJumpLoading);
18 Check.Equal("input-free-jump-loading",loading.Phase);Check.Equal(Now,loading.UnavailableSince!.Value);
19 Check.That(!gap.Observe(error,Now.AddMilliseconds(74999),true).PauseMonitoring);
20 Check.That(gap.Observe(error,Now.AddSeconds(75),true).PauseMonitoring);
21 }
22 [Test]public void MissingReleaseEvidenceAndTheOriginalJumpDeadlineNeverBorrowTheLoadingExemption()
23 {
24 var gap=new NativeFlightHealthGapPolicy();var error=new IOException("health transport");
25 gap.Observe(error,Now,false);
26 Check.That(gap.Observe(error,Now.AddSeconds(30),false).PauseMonitoring);
27 var witnessed=new NativeFlightHealthGapPolicy();witnessed.Observe(error,Now,true);
28 Check.That(!witnessed.Observe(error,Now.AddSeconds(31),true).PauseMonitoring);
29 // The coordinator withdraws its witness at the original jump's 75s,
30 // independent of this gap's later start or any intermediate polling.
31 Check.That(witnessed.Observe(error,Now.AddSeconds(60),false).PauseMonitoring);
32 }
33 [Test]public void UnknownIdentityAndWrappedProtocolErrorsNeverBecomeExpectedLoading()
34 {
35 foreach(var error in new Exception[]{new InvalidDataException("wrong ship"),new InvalidOperationException("Another controller owns navigation"),
36 new IOException("transport wrapper",new InvalidDataException("wrong build")),new IOException("unknown inner",new InvalidOperationException("unverified interface")),
37 new AggregateException(new IOException("pipe"),new InvalidDataException("reordered entity")),new FormatException("bad native value")})
38 {
39 Check.That(!NativeFlightHealthGapPolicy.RecoverableGap(error,default));
40 var result=new NativeFlightHealthGapPolicy().Observe(error,Now,true);
41 Check.That(result.PauseMonitoring&&!result.SafetyStop&&!result.ExpectedInputFreeJumpLoading);
42 }
43 }
44 [Test]public void OnlyKnownMissingHealthAndTransportErrorsReceiveReadOnlyGrace()
45 {
46 foreach(var error in new Exception[]{new FlightTelemetryUnavailableException("stale health"),new IOException("broken pipe"),new TimeoutException("read timeout"),
47 new OperationCanceledException("IPC read deadline"),new InvalidOperationException("Native player telemetry unavailable or stale"),
48 new AggregateException(new IOException("pipe"),new FlightTelemetryUnavailableException("stale"))})
49 Check.That(NativeFlightHealthGapPolicy.RecoverableGap(error,default));
50 using var stop=new CancellationTokenSource();stop.Cancel();
51 Check.That(!NativeFlightHealthGapPolicy.RecoverableGap(new IOException("pipe"),stop.Token));
52 var cancelled=false;try{new NativeFlightHealthGapPolicy().Observe(new IOException("pipe"),Now,true,stop.Token);}catch(OperationCanceledException){cancelled=true;}
53 Check.That(cancelled);
54 }
55 [Test]public void ALoadingWitnessNeverRenewsHealthInputAuthorityAndRecoveryNeedsAnActualNewSample()
56 {
57 var gap=new NativeFlightHealthGapPolicy();var sampled=Now;var error=new FlightTelemetryUnavailableException("stale");
58 Check.That(NativeFlightHealthGapPolicy.InputSampleFresh(sampled,Now.AddMilliseconds(500)));
59 Check.That(!NativeFlightHealthGapPolicy.InputSampleFresh(sampled,Now.AddMilliseconds(501)));
60 gap.Observe(error,Now,true);Check.That(!gap.Observe(error,Now.AddSeconds(31),true).PauseMonitoring);
61 Check.That(!NativeFlightHealthGapPolicy.InputSampleFresh(sampled,Now.AddSeconds(31)));
62 gap.Recovered();Check.That(gap.UnavailableSince is null);
63 Check.That(!NativeFlightHealthGapPolicy.InputSampleFresh(sampled,Now.AddSeconds(32)));
64 Check.That(NativeFlightHealthGapPolicy.InputSampleFresh(Now.AddSeconds(32),Now.AddSeconds(32)));
65 Check.That(!NativeFlightHealthGapPolicy.InputSampleFresh(Now.AddSeconds(33),Now.AddSeconds(32)));
66 }
67 [Test]public void RealHealthAndThreatProtectionPreemptsAnyLoadingWitness()
68 {
69 foreach(var error in new Exception[]{new FlightHealthPauseException(),new FlightThreatPauseException(),new AggregateException(new IOException("pipe"),new FlightHealthPauseException())})
70 {
71 var result=new NativeFlightHealthGapPolicy().Observe(error,Now,true);
72 Check.That(result.SafetyStop&&!result.PauseMonitoring&&!result.ExpectedInputFreeJumpLoading);Check.Equal("safety-stop",result.Phase);
73 }
74 }
75 }
Modified XFEEDToolBox.Test/NativeFlightTestProtectionTests.cs +61 -0
@@ -336,6 +336,67 @@ public class NativeFlightTestProtectionTests
336 336 }
337 337 finally{stop.Cancel();await server;}
338 338 }
339 [Test]public async Task LoadingWitnessStillBarsExpiredInputAndRecoveryRequiresAnActualFreshHealthRead()
340 {
341 var pid=Random.Shared.Next(380000000,390000000);using var stop=new CancellationTokenSource(TimeSpan.FromSeconds(6));
342 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;
343 var phases=new System.Collections.Concurrent.ConcurrentQueue<string>();
344 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}]};
345 var server=ServeHealth(pid,hash,_=>99,stop.Token,independent:true,observedAt:()=>Volatile.Read(ref resume)==0?at:DateTimeOffset.UtcNow);
346 try
347 {
348 await using var protection=await NativeFlightTestProtection.ArmAsync(pid,hash,sessions,()=>player,()=>{},stop.Token,automaticExit:false,
349 expectedInputFreeJumpLoading:()=>true,pauseAutomation:_=>Interlocked.Increment(ref pauses),diagnostics:diagnostic=>phases.Enqueue(diagnostic.Phase));
350 await Task.Delay(650,stop.Token);Check.That(!protection.Fresh&&!protection.Stopped);Check.Equal(0,pauses);
351 Check.Throws<FlightTelemetryUnavailableException>(()=>NativeFlightTestProtection.CheckBeforeInput(pid));
352 Interlocked.Exchange(ref resume,1);while(!protection.Fresh)await Task.Delay(20,stop.Token);
353 NativeFlightTestProtection.CheckBeforeInput(pid);
354 Check.Equal(1,phases.Count(phase=>phase=="health-gap"));Check.Equal(1,phases.Count(phase=>phase=="health-recovered"));
355 Check.Equal(0,pauses);Check.That(!sessions.Calls.Contains("exit"));
356 }
357 finally{stop.Cancel();await server;}
358 }
359 [Test]public async Task UnknownHealthFailurePersistsItsActualCauseBeforePausingInsteadOfCallingManualStop()
360 {
361 var pid=Random.Shared.Next(390000000,400000000);using var stop=new CancellationTokenSource(TimeSpan.FromSeconds(5));
362 var sessions=new Sessions();sessions.Target=sessions.Target with{ProcessId=pid};var hash=new string('a',64);var at=DateTimeOffset.UtcNow;var manualStops=0;
363 var evidence=new System.Collections.Concurrent.ConcurrentQueue<NativeFlightProtectionDiagnostic>();
364 var paused=new TaskCompletionSource<Exception>(TaskCreationOptions.RunContinuationsAsynchronously);
365 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}]};
366 var server=ServeHealth(pid,hash,_=>99,stop.Token,independent:true,healthFailure:sequence=>sequence>1?"Unknown original health source":null);
367 try
368 {
369 await using var protection=await NativeFlightTestProtection.ArmAsync(pid,hash,sessions,()=>player,()=>Interlocked.Increment(ref manualStops),stop.Token,automaticExit:false,
370 expectedInputFreeJumpLoading:()=>true,pauseAutomation:error=>
371 {Check.That(evidence.Any(item=>item.Phase=="monitoring-paused"));paused.TrySetResult(error);},diagnostics:evidence.Enqueue);
372 var reason=await paused.Task.WaitAsync(stop.Token);Check.That(reason is InvalidOperationException);Check.Equal("Unknown original health source",reason.Message);
373 await Task.Delay(160,stop.Token);
374 var diagnostic=evidence.Single(item=>item.Phase=="monitoring-paused");
375 Check.Equal(pid,diagnostic.ProcessId);Check.Equal(sessions.Target.StartedUtcTicks,diagnostic.StartedUtcTicks);Check.Equal("Solo",diagnostic.GameMode);
376 Check.Equal(1L,diagnostic.LastHealthSequence!.Value);Check.Equal(99UL,diagnostic.LastHealthEntity!.Value);
377 Check.Equal(reason.Message,diagnostic.ExceptionMessage!);Check.That(diagnostic.UnavailableSince is not null);
378 Check.That(protection.Stopped&&!protection.Fresh);Check.Equal(0,manualStops);Check.That(!sessions.Calls.Contains("exit"));
379 }
380 finally{stop.Cancel();await server;}
381 }
382 [Test]public async Task ActualDamageUsesTheExistingSafetyStopEvenWithAReleasedJumpLoadingWitness()
383 {
384 var pid=Random.Shared.Next(400000000,410000000);using var stop=new CancellationTokenSource(TimeSpan.FromSeconds(5));
385 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;
386 var phases=new System.Collections.Concurrent.ConcurrentQueue<string>();
387 var player=new PlayerState{CommanderId="F1",InGame=true,GameMode="Solo",CurrentShipId=7,LoadGameAt=at.AddMinutes(-1),StatusAt=at,
388 Status=new((1L<<24)|8,0,1,1,0,null,null,null,null,[]),Ships=[new(){ShipId=7,HullHealth=1,HullHealthAt=at,ConfigurationAt=at}]};
389 var server=ServeHealth(pid,hash,_=>99,stop.Token,hull:sequence=>sequence<4?1:.49,independent:true);
390 try
391 {
392 await using var protection=await NativeFlightTestProtection.ArmAsync(pid,hash,sessions,()=>player,()=>
393 {Check.That(phases.Contains("safety-stop"));Interlocked.Increment(ref safetyStops);},stop.Token,automaticExit:false,
394 expectedInputFreeJumpLoading:()=>true,pauseAutomation:_=>Interlocked.Increment(ref monitoringPauses),diagnostics:diagnostic=>phases.Enqueue(diagnostic.Phase));
395 while(Volatile.Read(ref safetyStops)==0)await Task.Delay(20,stop.Token);
396 Check.That(protection.Stopped&&!protection.Fresh);Check.Equal(0,monitoringPauses);Check.That(!sessions.Calls.Contains("exit"));
397 }
398 finally{stop.Cancel();await server;}
399 }
339 400 [Test] public async Task ProductionMonitorSharesNativeFeedAndDisposalReleasesTheInputInterlock()
340 401 {
341 402 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};
Modified XFEEDToolBox/ViewModels/NavigationViewModel.cs +7 -2
@@ -120,14 +120,14 @@ public partial class ToolboxViewModel
120 120 {
121 121 if(mirrored.Cleared)
122 122 {
123 if(NavigationActive)StopNavigation();
123 if(NavigationActive)StopForObservedRouteChange("游戏航线已清空,已释放原导航输入。");
124 124 navigationItinerary.Reset();navigationDisplayRoute=[];LastConfirmedGameRoute=null;
125 125 synchronizingGameRoute=true;try{NavigationDestination="";}finally{synchronizingGameRoute=false;}
126 126 NavigationStatus="游戏航线已取消。";NavigationStatusSeverity=InfoBarSeverity.Informational;
127 127 }
128 128 else if(mirrored.Destination is {} gameDestination)
129 129 {
130 if(mirrored.Replaced&&NavigationActive)StopNavigation();
130 if(mirrored.Replaced&&NavigationActive)StopForObservedRouteChange("游戏航线终点已改变,已释放原导航输入。");
131 131 synchronizingGameRoute=true;try{NavigationDestination=gameDestination.Name;}finally{synchronizingGameRoute=false;}
132 132 var system=new GalacticSystem(gameDestination.Name,gameDestination.Position,gameDestination.SystemAddress,gameDestination.StarClass,"游戏航线");
133 133 galaxyCache[system.Name]=system;
@@ -358,6 +358,11 @@ public partial class ToolboxViewModel
358 358 private int navigationUserStopVersion;
359 359 internal int NavigationUserStopVersion=>Volatile.Read(ref navigationUserStopVersion);
360 360 private void StopOwnedNavigation(){navigationStartStop?.Cancel();runtime.FlightAutomation.Stop();runtime.Navigator.Stop();}
361 private void StopForObservedRouteChange(string reason)
362 {
363 runtime.FlightAutomation.PauseForMonitoring(new InvalidOperationException(reason));
364 runtime.Navigator.Stop();
365 }
361 366 internal void StopNavigationForScoAcceptance()
362 367 {
363 368 if(!Environment.GetCommandLineArgs().Contains("--accept-sco"))throw new InvalidOperationException("SCO QA stop requires its explicit launch entry.");
Modified XFEEDToolBox/ViewModels/ShieldRestartViewModel.cs +20 -3
Modified docs/SCO-EXIT-AND-SESSION-RECOVERY-20261027.md +11 -1