11using System . Diagnostics ;
2+ using System . Runtime . InteropServices ;
23using Basis . Logging ;
34using Basis . Network ;
45using Basis . Config ;
@@ -13,6 +14,7 @@ partial class Program
1314 private const double MovementIntervalMs = 90.0 ;
1415 private const int MaxVoiceCatchUpFrames = 5 ;
1516 private static volatile bool _running = true ;
17+ private static int _shutdownStarted ;
1618
1719 /// <summary>Driver iterations that took longer than DriverTickMs — the harness falling behind.</summary>
1820 private static long DriverOverruns ;
@@ -69,19 +71,31 @@ public static async Task Main(string[] args)
6971 var clientManager = new ClientManager ( ) ;
7072 clientManager . Prepare ( ) ;
7173
72- AppDomain . CurrentDomain . ProcessExit += ( _ , __ ) =>
74+ // Every way this process is asked to stop ends in the same place, and all of them are
75+ // reachable: Ctrl-C interactively, SIGTERM from docker stop or systemd, a "stop" line
76+ // or a closed stdin from a harness driving it, and ProcessExit as the backstop for a
77+ // plain return. Before this, only ProcessExit was handled, and ProcessExit runs on a
78+ // budget measured in seconds - so a population of a few thousand never finished
79+ // announcing and the server timed most of them out instead of being told.
80+ AppDomain . CurrentDomain . ProcessExit += ( _ , __ ) => Shutdown ( clientManager ) ;
81+
82+ Console . CancelKeyPress += ( _ , e ) =>
7383 {
74- Console . WriteLine ( "Shutting down..." ) ;
75- _running = false ;
76- MicrophoneCapture . Stop ( ) ;
77- clientManager . StopClientsAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
78- // Close the capture file here rather than relying on the finalizer: a run is
79- // normally ended with Ctrl-C, and a half-written last record would make the
80- // whole capture unreadable to the trainer.
81- string captureSummary = BundleCaptureSink . Finish ( ) ;
82- if ( captureSummary != null ) Console . WriteLine ( captureSummary ) ;
84+ // Cancel the default kill so shutdown runs to completion rather than racing it.
85+ e . Cancel = true ;
86+ Shutdown ( clientManager ) ;
87+ Environment . Exit ( 0 ) ;
8388 } ;
8489
90+ using var sigTerm = PosixSignalRegistration . Create ( PosixSignal . SIGTERM , ctx =>
91+ {
92+ ctx . Cancel = true ;
93+ Shutdown ( clientManager ) ;
94+ Environment . Exit ( 0 ) ;
95+ } ) ;
96+
97+ StartStopRequestWatcher ( clientManager ) ;
98+
8599 MovementSender . Initialize ( clientManager . ClientCount ) ;
86100 MovementSender . VoiceSender . Initialize ( clientManager . ClientCount ) ;
87101
@@ -185,6 +199,81 @@ public static async Task Main(string[] args)
185199 await Task . Delay ( - 1 ) ; // keep main alive
186200 }
187201
202+
203+ /// <summary>
204+ /// Runs the shutdown once, whoever asks for it first.
205+ ///
206+ /// <para>Ctrl-C, SIGTERM and ProcessExit can all fire for one stop - Ctrl-C in particular
207+ /// runs its handler and then ProcessExit - so this has to be idempotent or the population
208+ /// is torn down twice and the second pass throws on already-disposed transports.</para>
209+ /// </summary>
210+ private static void Shutdown ( ClientManager clientManager )
211+ {
212+ if ( Interlocked . Exchange ( ref _shutdownStarted , 1 ) != 0 ) return ;
213+
214+ Console . WriteLine ( "Shutting down..." ) ;
215+ _running = false ;
216+ MicrophoneCapture . Stop ( ) ;
217+ clientManager . StopClientsAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
218+ // Close the capture file here rather than relying on the finalizer: a run is
219+ // normally ended with Ctrl-C, and a half-written last record would make the
220+ // whole capture unreadable to the trainer.
221+ string captureSummary = BundleCaptureSink . Finish ( ) ;
222+ if ( captureSummary != null ) Console . WriteLine ( captureSummary ) ;
223+ }
224+
225+ /// <summary>
226+ /// Lets whatever started this process ask it to leave cleanly.
227+ ///
228+ /// <para>A harness cannot send SIGTERM on Windows, and killing the process runs no managed
229+ /// code at all - which is exactly the case that leaves a server holding several thousand
230+ /// peers until they time out. Watching stdin gives every platform one graceful stop: a
231+ /// "stop" or "quit" line, or simply closing the stream, both mean leave now.</para>
232+ ///
233+ /// <para>Harmless when nobody is driving it. An interactive run just blocks on a console
234+ /// nobody types into, and this thread is a background one, so it never holds up exit.</para>
235+ /// </summary>
236+ private static void StartStopRequestWatcher ( ClientManager clientManager )
237+ {
238+ var thread = new Thread ( ( ) =>
239+ {
240+ try
241+ {
242+ while ( true )
243+ {
244+ string line = Console . ReadLine ( ) ;
245+
246+ // End of stream is NOT a stop request. A process started with stdin closed
247+ // - nohup, systemd, a detached launch - reads EOF immediately, and treating
248+ // that as "leave now" would shut the run down the moment it started. Only an
249+ // explicit word means stop; EOF just means nobody is going to send one.
250+ if ( line == null ) return ;
251+
252+ line = line . Trim ( ) ;
253+ if ( line . Equals ( "stop" , StringComparison . OrdinalIgnoreCase ) ||
254+ line . Equals ( "quit" , StringComparison . OrdinalIgnoreCase ) ||
255+ line . Equals ( "exit" , StringComparison . OrdinalIgnoreCase ) )
256+ {
257+ break ;
258+ }
259+ }
260+ }
261+ catch
262+ {
263+ // No console to read at all. Nothing to wait for, and nothing to stop.
264+ return ;
265+ }
266+
267+ Shutdown ( clientManager ) ;
268+ Environment . Exit ( 0 ) ;
269+ } )
270+ {
271+ Name = "StopRequestWatcher" ,
272+ IsBackground = true ,
273+ } ;
274+ thread . Start ( ) ;
275+ }
276+
188277 public static void StopClient ( ClientManager manager , int index )
189278 {
190279 var peer = Volatile . Read ( ref manager . FinalPeers [ index ] ) ;
0 commit comments