Skip to content

Commit 39d705d

Browse files
authored
Merge pull request #25 from Zaknin/codex/phase-34a-lifecycle-observability
Phase 34A: safely supersede SteamVR runtime-loss cleanup
2 parents ac5c37c + 8321a68 commit 39d705d

187 files changed

Lines changed: 46403 additions & 3627 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.gitattributes‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
PimaxVrcSupervisor.Tests/Fixtures/Phase33A/*.json text eol=lf

‎.github/workflows/release.yml‎

Lines changed: 261 additions & 265 deletions
Large diffs are not rendered by default.

‎.github/workflows/sign-release-assets.yml‎

Lines changed: 0 additions & 105 deletions
This file was deleted.

‎.github/workflows/validation.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ jobs:
4343
shell: pwsh
4444
run: |
4545
dotnet build .\PimaxVrcSupervisor\PimaxVrcSupervisor.csproj -c Release
46+
dotnet build .\PimaxVrcSupervisor.UpdateWorker\PimaxVrcSupervisor.UpdateWorker.csproj -c Release
4647
dotnet build .\PimaxVrcSupervisor.ConfigEditor\PimaxVrcSupervisor.ConfigEditor.csproj -c Release
4748
dotnet build .\PimaxVrcSupervisor.SteamVrHost\PimaxVrcSupervisor.SteamVrHost.csproj -c Release
4849
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
using System.ComponentModel;
2+
using System.Diagnostics;
3+
using System.Text;
4+
using System.Text.Json;
5+
using System.Text.RegularExpressions;
6+
7+
namespace PimaxVrcSupervisor.Updates;
8+
9+
internal sealed record UpdateCheckWorkerProcessOutput(
10+
int? ExitCode,
11+
string StandardOutput,
12+
string StandardError,
13+
bool TimedOut,
14+
bool StandardOutputExceeded,
15+
bool StandardErrorExceeded);
16+
17+
internal interface IUpdateCheckWorkerProcessRunner
18+
{
19+
Task<UpdateCheckWorkerProcessOutput> RunAsync(
20+
ProcessStartInfo startInfo,
21+
TimeSpan timeout,
22+
CancellationToken cancellationToken);
23+
}
24+
25+
internal sealed class ConfiguratorStandaloneUpdateCheckLauncher : IStandaloneUpdateCheckLauncher
26+
{
27+
private const string UpdateWorkerExecutableName = "PimaxVrcSupervisor.UpdateWorker.exe";
28+
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(45);
29+
private static readonly Regex StableVersionPattern = new(
30+
@"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$",
31+
RegexOptions.CultureInvariant | RegexOptions.NonBacktracking);
32+
private readonly string _installationDirectory;
33+
private readonly IUpdateCheckWorkerProcessRunner _processRunner;
34+
private readonly TimeSpan _timeout;
35+
36+
public ConfiguratorStandaloneUpdateCheckLauncher()
37+
: this(AppContext.BaseDirectory, new SystemUpdateCheckWorkerProcessRunner(), DefaultTimeout)
38+
{
39+
}
40+
41+
internal ConfiguratorStandaloneUpdateCheckLauncher(
42+
string installationDirectory,
43+
IUpdateCheckWorkerProcessRunner processRunner,
44+
TimeSpan timeout)
45+
{
46+
ArgumentException.ThrowIfNullOrWhiteSpace(installationDirectory);
47+
if (timeout <= TimeSpan.Zero || timeout > TimeSpan.FromMinutes(2))
48+
{
49+
throw new ArgumentOutOfRangeException(nameof(timeout));
50+
}
51+
52+
_installationDirectory = Path.GetFullPath(installationDirectory);
53+
_processRunner = processRunner ?? throw new ArgumentNullException(nameof(processRunner));
54+
_timeout = timeout;
55+
}
56+
57+
public async Task<StandaloneUpdateCheckResultV1> RunAsync(CancellationToken cancellationToken)
58+
{
59+
cancellationToken.ThrowIfCancellationRequested();
60+
var executablePath = Path.GetFullPath(Path.Combine(_installationDirectory, UpdateWorkerExecutableName));
61+
if (!string.Equals(
62+
Path.GetDirectoryName(executablePath)?.TrimEnd(Path.DirectorySeparatorChar),
63+
_installationDirectory.TrimEnd(Path.DirectorySeparatorChar),
64+
StringComparison.OrdinalIgnoreCase)
65+
|| !File.Exists(executablePath))
66+
{
67+
return Failure("worker_missing", "The sibling Supervisor update-check worker is unavailable.");
68+
}
69+
70+
var startInfo = new ProcessStartInfo
71+
{
72+
FileName = executablePath,
73+
WorkingDirectory = _installationDirectory,
74+
UseShellExecute = false,
75+
RedirectStandardOutput = true,
76+
RedirectStandardError = true,
77+
CreateNoWindow = true,
78+
WindowStyle = ProcessWindowStyle.Hidden
79+
};
80+
startInfo.ArgumentList.Add("--update-check-once");
81+
startInfo.ArgumentList.Add("--source");
82+
startInfo.ArgumentList.Add("configurator");
83+
84+
UpdateCheckWorkerProcessOutput output;
85+
try
86+
{
87+
output = await _processRunner.RunAsync(startInfo, _timeout, cancellationToken).ConfigureAwait(false);
88+
}
89+
catch (Exception exception) when (exception is Win32Exception or IOException or InvalidOperationException)
90+
{
91+
return Failure("worker_start_failed", "The sibling Supervisor update-check worker could not be started.");
92+
}
93+
94+
if (output.TimedOut)
95+
{
96+
return Failure("worker_timeout", "The standalone update check exceeded its bounded wait time and may finish in the background.");
97+
}
98+
99+
if (output.StandardOutputExceeded || output.StandardErrorExceeded)
100+
{
101+
return Failure("worker_output_oversized", "The standalone update-check worker returned oversized output.");
102+
}
103+
104+
return ParseResult(output.StandardOutput, output.ExitCode);
105+
}
106+
107+
private static StandaloneUpdateCheckResultV1 ParseResult(string standardOutput, int? exitCode)
108+
{
109+
if (Encoding.UTF8.GetByteCount(standardOutput) > StandaloneUpdateCheckJson.MaximumOutputBytes)
110+
{
111+
return Failure("worker_output_oversized", "The standalone update-check worker returned oversized output.");
112+
}
113+
114+
var lines = standardOutput
115+
.Replace("\r\n", "\n", StringComparison.Ordinal)
116+
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
117+
if (lines.Length != 1)
118+
{
119+
return Failure("worker_output_invalid", "The standalone update-check worker returned an invalid result.");
120+
}
121+
122+
try
123+
{
124+
var result = JsonSerializer.Deserialize<StandaloneUpdateCheckResultV1>(
125+
lines[0],
126+
StandaloneUpdateCheckJson.Options);
127+
if (result is null
128+
|| result.SchemaVersion != 1
129+
|| !string.Equals(result.Operation, "update-check-once", StringComparison.Ordinal)
130+
|| !IsSafeIdentifier(result.ResultCode, 64)
131+
|| string.IsNullOrEmpty(result.Summary)
132+
|| result.Summary.Length > 256
133+
|| result.Summary.Any(char.IsControl)
134+
|| result.LatestVerifiedVersion is not null && !StableVersionPattern.IsMatch(result.LatestVerifiedVersion)
135+
|| result.Success && result.Status is null
136+
|| result.Status is not null
137+
&& (result.Status.SchemaVersion != 1
138+
|| !string.Equals(result.Status.Channel, "Stable", StringComparison.Ordinal)
139+
|| !string.Equals(result.LatestVerifiedVersion, result.Status.LatestVerifiedVersion, StringComparison.Ordinal)
140+
|| result.UpdateAvailable != result.Status.UpdateAvailable)
141+
|| exitCode is null
142+
|| (exitCode == 0) != result.Success)
143+
{
144+
return Failure("worker_output_invalid", "The standalone update-check worker returned an invalid result.");
145+
}
146+
147+
return result;
148+
}
149+
catch (Exception exception) when (exception is JsonException or NotSupportedException)
150+
{
151+
return Failure("worker_output_invalid", "The standalone update-check worker returned an invalid result.");
152+
}
153+
}
154+
155+
private static bool IsSafeIdentifier(string? value, int maximumLength)
156+
=> value is not null
157+
&& value.Length is > 0
158+
&& value.Length <= maximumLength
159+
&& value.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-');
160+
161+
private static StandaloneUpdateCheckResultV1 Failure(string code, string summary)
162+
=> new(
163+
SchemaVersion: 1,
164+
Operation: "update-check-once",
165+
ResultCode: code,
166+
Success: false,
167+
LatestVerifiedVersion: null,
168+
UpdateAvailable: false,
169+
Summary: summary,
170+
Status: null);
171+
}
172+
173+
internal sealed class SystemUpdateCheckWorkerProcessRunner : IUpdateCheckWorkerProcessRunner
174+
{
175+
private const int MaximumStandardErrorCharacters = 4 * 1024;
176+
177+
public async Task<UpdateCheckWorkerProcessOutput> RunAsync(
178+
ProcessStartInfo startInfo,
179+
TimeSpan timeout,
180+
CancellationToken cancellationToken)
181+
{
182+
cancellationToken.ThrowIfCancellationRequested();
183+
var process = new Process { StartInfo = startInfo };
184+
if (!process.Start())
185+
{
186+
process.Dispose();
187+
throw new InvalidOperationException("The update-check worker process did not start.");
188+
}
189+
190+
var standardOutput = ReadBoundedAsync(
191+
process.StandardOutput,
192+
StandaloneUpdateCheckJson.MaximumOutputBytes);
193+
var standardError = ReadBoundedAsync(process.StandardError, MaximumStandardErrorCharacters);
194+
var exit = process.WaitForExitAsync(CancellationToken.None);
195+
var completed = await Task.WhenAny(exit, Task.Delay(timeout, CancellationToken.None)).ConfigureAwait(false);
196+
if (completed != exit)
197+
{
198+
_ = ObserveCompletionAndDisposeAsync(process, exit, standardOutput, standardError);
199+
return new UpdateCheckWorkerProcessOutput(
200+
ExitCode: null,
201+
StandardOutput: "",
202+
StandardError: "",
203+
TimedOut: true,
204+
StandardOutputExceeded: false,
205+
StandardErrorExceeded: false);
206+
}
207+
208+
await exit.ConfigureAwait(false);
209+
var stdout = await standardOutput.ConfigureAwait(false);
210+
var stderr = await standardError.ConfigureAwait(false);
211+
var exitCode = process.ExitCode;
212+
process.Dispose();
213+
return new UpdateCheckWorkerProcessOutput(
214+
exitCode,
215+
stdout.Text,
216+
stderr.Text,
217+
TimedOut: false,
218+
stdout.Exceeded,
219+
stderr.Exceeded);
220+
}
221+
222+
private static async Task<BoundedText> ReadBoundedAsync(StreamReader reader, int maximumCharacters)
223+
{
224+
var builder = new StringBuilder(Math.Min(maximumCharacters, 4096));
225+
var buffer = new char[1024];
226+
var exceeded = false;
227+
while (true)
228+
{
229+
var count = await reader.ReadAsync(buffer.AsMemory(), CancellationToken.None).ConfigureAwait(false);
230+
if (count == 0)
231+
{
232+
break;
233+
}
234+
235+
var available = Math.Max(0, maximumCharacters - builder.Length);
236+
if (available > 0)
237+
{
238+
builder.Append(buffer, 0, Math.Min(available, count));
239+
}
240+
241+
exceeded |= count > available;
242+
}
243+
244+
return new BoundedText(builder.ToString(), exceeded);
245+
}
246+
247+
private static async Task ObserveCompletionAndDisposeAsync(
248+
Process process,
249+
Task exit,
250+
Task<BoundedText> standardOutput,
251+
Task<BoundedText> standardError)
252+
{
253+
try
254+
{
255+
await exit.ConfigureAwait(false);
256+
await Task.WhenAll(standardOutput, standardError).ConfigureAwait(false);
257+
}
258+
catch
259+
{
260+
}
261+
finally
262+
{
263+
process.Dispose();
264+
}
265+
}
266+
267+
private sealed record BoundedText(string Text, bool Exceeded);
268+
}

‎PimaxVrcSupervisor.ConfigEditor/PimaxVrcSupervisor.ConfigEditor.csproj‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
<Compile Include="..\PimaxVrcSupervisor\BaseStationSupport.cs" Link="BaseStationSupport.cs" />
2222
<Compile Include="..\PimaxVrcSupervisor\MigrationSupport.cs" Link="MigrationSupport.cs" />
2323
<Compile Include="..\PimaxVrcSupervisor\ScheduledTaskPathValidation.cs" Link="ScheduledTaskPathValidation.cs" />
24+
<Compile Include="..\PimaxVrcSupervisor\StartupLaunchPlanning.cs" Link="StartupLaunchPlanning.cs" />
25+
<Compile Include="..\PimaxVrcSupervisor\UpdateBridgeContract.cs" Link="UpdateBridgeContract.cs" />
26+
<Compile Include="..\PimaxVrcSupervisor\StandaloneUpdateCheckContract.cs" Link="StandaloneUpdateCheckContract.cs" />
2427
<None Include="..\PimaxVrcSupervisor\supervisor.config.json" Link="supervisor.config.json">
2528
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
2629
</None>

0 commit comments

Comments
 (0)