Skip to content

Commit 8b64026

Browse files
committed
ready to trial live
1 parent 134fd60 commit 8b64026

20 files changed

Lines changed: 1115 additions & 81 deletions

File tree

Basis Server/BasisBenchAgent/Program.cs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,8 @@ private static AgentResponse StartClient(AgentRequest request)
192192
UseShellExecute = false,
193193
RedirectStandardOutput = true,
194194
RedirectStandardError = true,
195+
// Open only so the client can be asked to leave the server before it is killed.
196+
RedirectStandardInput = true,
195197
};
196198

197199
Process process = Process.Start(info) ?? throw new InvalidOperationException("could not start the load client");
@@ -265,7 +267,7 @@ private static void StopClient()
265267
if (process == null) return;
266268
try
267269
{
268-
if (!process.HasExited)
270+
if (!process.HasExited && !TryStopGracefully(process, TimeSpan.FromSeconds(10)))
269271
{
270272
process.Kill(entireProcessTree: true);
271273
process.WaitForExit(15000);
@@ -279,6 +281,35 @@ private static void StopClient()
279281
}
280282
}
281283

284+
/// <summary>
285+
/// Asks the load client to leave the server before killing it, and returns whether it did.
286+
///
287+
/// <para>Killing a process runs no managed code, so every client it was simulating vanishes
288+
/// without a word and the server holds each one until it times out — which pollutes the next
289+
/// run's population and its admission timings. Writing to stdin is the one graceful stop that
290+
/// works on both platforms: there is no SIGTERM to send on Windows, and a console app cannot be
291+
/// asked to close politely any other way.</para>
292+
///
293+
/// <para>The kill still happens if it does not go quietly. This buys a clean departure when it
294+
/// is available; it never trades away the guarantee that the process dies.</para>
295+
/// </summary>
296+
private static bool TryStopGracefully(Process process, TimeSpan timeout)
297+
{
298+
try
299+
{
300+
process.StandardInput.WriteLine("stop");
301+
process.StandardInput.Flush();
302+
}
303+
catch
304+
{
305+
return false;
306+
}
307+
308+
try { return process.WaitForExit((int)timeout.TotalMilliseconds); }
309+
catch { return false; }
310+
}
311+
312+
282313
private static string DiscoverClientDirectory()
283314
{
284315
// Beside the agent first - that is how it ships - then the development layout.

Basis Server/BasisNetworkClient/NetworkClient.cs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,29 @@ public void Update(float elapsedMilliseconds)
5050
}
5151
public void Disconnect()
5252
{
53-
IsInUse = false;
5453
BNL.Log("Client Called Disconnect from server");
54+
NotifyServerOfDeparture();
55+
Shutdown();
56+
BNL.Log("Worker thread stopped.");
57+
}
58+
/// <summary>
59+
/// Tells the server this client is leaving, and does nothing else.
60+
///
61+
/// <para>Split out from <see cref="Shutdown"/> because the two costs are nothing alike. This
62+
/// writes one datagram straight to the socket and returns; shutting the transport down closes
63+
/// the socket and joins its logic thread. Anything stopping a whole population has to get
64+
/// every one of these out before it starts paying for the teardown, or the last clients are
65+
/// still queued behind thread joins when the process is killed and the server is left to time
66+
/// them out one by one.</para>
67+
/// </summary>
68+
public void NotifyServerOfDeparture()
69+
{
70+
IsInUse = false;
5571
peer?.Disconnect();
72+
}
73+
/// <summary>Closes the socket and joins the transport's threads.</summary>
74+
public void Shutdown()
75+
{
5676
client?.Stop();
57-
58-
BNL.Log("Worker thread stopped.");
5977
}
6078
}

Basis Server/BasisNetworkClientConsole/BasisNetworkClientConsole/Client/ClientManager.cs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,10 +252,46 @@ public async Task ReconnectClientAsync(int index)
252252
BNL.Log($"Reconnected: {name} ({identity.Did}) at index {index}");
253253
}
254254
}
255+
/// <summary>
256+
/// Leaves the server cleanly, then tears down.
257+
///
258+
/// <para>The two passes are the point. Departure notices are one datagram each and go out
259+
/// in a few milliseconds for the whole population; the teardown behind them closes a socket
260+
/// and joins a thread per client and takes far longer than the shutdown budget a killed or
261+
/// Ctrl-C'd process gets. Interleaving them meant client N was still waiting on client
262+
/// N-1's thread join when the process died, so most of a run's clients never told the
263+
/// server anything and it had to time each of them out instead.</para>
264+
///
265+
/// <para>Nothing is logged per client here for the same reason: a log line costs a console
266+
/// lock and a file write, and a few thousand of them is itself enough to run the budget
267+
/// out.</para>
268+
/// </summary>
255269
public Task StopClientsAsync()
256270
{
257-
if (FinalClients != null)
258-
foreach (var client in FinalClients) client?.Disconnect();
271+
var clients = FinalClients;
272+
if (clients == null) return Task.CompletedTask;
273+
274+
int announced = 0;
275+
foreach (var client in clients)
276+
{
277+
try
278+
{
279+
if (client == null) continue;
280+
client.NotifyServerOfDeparture();
281+
announced++;
282+
}
283+
catch
284+
{
285+
// A client that is already gone cannot leave twice, and one failure must not
286+
// stop the rest of the population from announcing.
287+
}
288+
}
289+
BNL.Log($"Told the server {announced} client(s) are leaving; tearing down.");
290+
291+
foreach (var client in clients)
292+
{
293+
try { client?.Shutdown(); } catch { }
294+
}
259295
return Task.CompletedTask;
260296
}
261297
public Configuration CreateConfig()

Basis Server/BasisNetworkClientConsole/BasisNetworkClientConsole/Program.cs

Lines changed: 99 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Diagnostics;
2+
using System.Runtime.InteropServices;
23
using Basis.Logging;
34
using Basis.Network;
45
using 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]);

Basis Server/BasisNetworkCompute/GpuDistanceSolver.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,17 @@ private GpuDistanceSolver(Context context, Accelerator accelerator)
7171
return null;
7272
}
7373

74-
accelerator = device.CreateAccelerator(context);
74+
// ScheduleBlockingSync, not the default.
75+
//
76+
// CUDA's default waits for a kernel by spinning, which is right for a process whose
77+
// only job is the device and exactly wrong here: the point of the offload is to hand
78+
// cores back to the send phase and the transport's per-peer pass, and a spinning wait
79+
// hands back nothing. Measured on one sweep at 1000 players: 1.04 ms of CPU burned
80+
// waiting with the default, 0.00 ms blocking, for the same work. Costs a few hundred
81+
// microseconds of wakeup latency on a pass that runs at most a few times a second.
82+
accelerator = device is CudaDevice cuda
83+
? cuda.CreateCudaAccelerator(context, CudaAcceleratorFlags.ScheduleBlockingSync)
84+
: device.CreateAccelerator(context);
7585
var solver = new GpuDistanceSolver(context, accelerator);
7686
solver.Backend = device.AcceleratorType == AcceleratorType.Cuda ? "cuda" : "opencl";
7787
return solver;

Basis Server/BasisServerBenchmark/Harness/LoadClientDriver.cs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ public void Start(RunOptions options)
6363
UseShellExecute = false,
6464
RedirectStandardOutput = true,
6565
RedirectStandardError = true,
66+
// Open only so the client can be asked to leave the server before it is killed.
67+
RedirectStandardInput = true,
6668
};
6769

6870
Process process = Process.Start(info) ?? throw new InvalidOperationException($"Could not start {exe}");
@@ -92,7 +94,7 @@ public void Stop()
9294

9395
try
9496
{
95-
if (!process.HasExited)
97+
if (!process.HasExited && !TryStopGracefully(process, TimeSpan.FromSeconds(10)))
9698
{
9799
process.Kill(entireProcessTree: true);
98100
process.WaitForExit(15000);
@@ -102,6 +104,34 @@ public void Stop()
102104
finally { try { process.Dispose(); } catch { } }
103105
}
104106

107+
/// <summary>
108+
/// Asks the load client to leave the server before killing it, and returns whether it did.
109+
///
110+
/// <para>Killing a process runs no managed code, so every client it was simulating vanishes
111+
/// without a word and the server holds each one until it times out — which pollutes the next
112+
/// run's population and its admission timings. Writing to stdin is the one graceful stop that
113+
/// works on both platforms: there is no SIGTERM to send on Windows, and a console app cannot be
114+
/// asked to close politely any other way.</para>
115+
///
116+
/// <para>The kill still happens if it does not go quietly. This buys a clean departure when it
117+
/// is available; it never trades away the guarantee that the process dies.</para>
118+
/// </summary>
119+
private static bool TryStopGracefully(Process process, TimeSpan timeout)
120+
{
121+
try
122+
{
123+
process.StandardInput.WriteLine("stop");
124+
process.StandardInput.Flush();
125+
}
126+
catch
127+
{
128+
return false;
129+
}
130+
131+
try { return process.WaitForExit((int)timeout.TotalMilliseconds); }
132+
catch { return false; }
133+
}
134+
105135
public void Dispose() => Stop();
106136

107137
private static string Executable(string directory)

0 commit comments

Comments
 (0)