재부팅 복구와 학습자 패널을 정리

This commit is contained in:
Yun Chan 2026-08-28 18:07:28 +09:00
parent dcb76a2373
commit 99779a6ab2
10 changed files with 442 additions and 25 deletions

View file

@ -16,6 +16,160 @@ WHISPER_START = (SCRIPTS / "start-local-whisper-stt.ps1").read_text(
class PublicRuntimeVoiceContractTest(unittest.TestCase):
def test_recovery_is_serialized_and_lock_is_always_released(self) -> None:
lock = PUBLIC_RUNTIME.index("$recoveryLock = Enter-RecoveryLock")
main_try = PUBLIC_RUNTIME.index("try {", lock)
first_runtime_mutation = PUBLIC_RUNTIME.index("Stop-UvicornByPort", main_try)
finalizer = PUBLIC_RUNTIME.rindex("} finally {")
dispose = PUBLIC_RUNTIME.index("$recoveryLock.Dispose()", finalizer)
self.assertLess(lock, main_try)
self.assertLess(main_try, first_runtime_mutation)
self.assertLess(finalizer, dispose)
self.assertIn(
'"$env:LOCALAPPDATA\\Vignette\\public-runtime-start.lock"',
PUBLIC_RUNTIME,
)
def test_recovery_lock_is_exclusive_and_reusable_in_windows_powershell(self) -> None:
powershell = shutil.which("powershell.exe")
if powershell is None:
self.skipTest("Windows PowerShell 5.1 is not available")
lock_function = PUBLIC_RUNTIME[
PUBLIC_RUNTIME.index("function Enter-RecoveryLock") :
PUBLIC_RUNTIME.index(
"# boot task, watchdog, 수동 승격이 같은 포트와 프로세스를 동시에 교체하지 못하게 한다."
)
].strip()
with tempfile.TemporaryDirectory() as temporary_directory:
lock_path = str(Path(temporary_directory) / "runtime.lock").replace("'", "''")
harness = Path(temporary_directory) / "recovery-lock.ps1"
harness.write_text(
lock_function
+ f"""
$first = Enter-RecoveryLock -LockPath '{lock_path}' -WaitSeconds 0
try {{
try {{
$second = Enter-RecoveryLock -LockPath '{lock_path}' -WaitSeconds 0
exit 2
}} catch {{
if ($_.Exception.Message -notlike '*already in progress*') {{ exit 3 }}
}}
}} finally {{
$first.Dispose()
}}
$third = Enter-RecoveryLock -LockPath '{lock_path}' -WaitSeconds 0
$third.Dispose()
exit 0
""",
encoding="utf-8-sig",
)
completed = subprocess.run(
[
powershell,
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
str(harness),
],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=30,
check=False,
)
self.assertEqual(
completed.returncode,
0,
msg=f"stdout={completed.stdout}\nstderr={completed.stderr}",
)
def test_cold_start_waits_are_bounded_and_configurable(self) -> None:
for expected in (
"[int]$ApiReadySeconds = 180",
"[int]$VoiceApiReadySeconds = 90",
"[int]$WebBuildTimeoutSeconds = 600",
"-TimeoutSec $ApiReadySeconds",
"-TimeoutSec $VoiceApiReadySeconds",
"$build.WaitForExit($WebBuildTimeoutSeconds * 1000)",
"Web build timed out after $WebBuildTimeoutSeconds seconds",
):
with self.subTest(expected=expected):
self.assertIn(expected, PUBLIC_RUNTIME)
def test_web_build_timeout_stops_the_owned_process_tree(self) -> None:
powershell = shutil.which("powershell.exe")
if powershell is None:
self.skipTest("Windows PowerShell 5.1 is not available")
tree_functions = PUBLIC_RUNTIME[
PUBLIC_RUNTIME.index("function Stop-ProcessTreeBounded") :
PUBLIC_RUNTIME.index("function Get-UvicornProcessesByPort")
].strip()
with tempfile.TemporaryDirectory() as temporary_directory:
harness = Path(temporary_directory) / "process-tree.ps1"
child = Path(temporary_directory) / "child-sleeper.ps1"
child_pid = Path(temporary_directory) / "child.pid"
quoted_child_pid = str(child_pid).replace("'", "''")
child.write_text(
f"$PID | Set-Content -LiteralPath '{quoted_child_pid}' -Encoding ascii\n"
"Start-Sleep -Seconds 300\n",
encoding="utf-8-sig",
)
quoted_child = str(child).replace("'", "''")
quoted_child_pid_for_harness = str(child_pid).replace("'", "''")
harness.write_text(
tree_functions
+ f'''
$root = Start-Process -FilePath 'cmd.exe' `
-ArgumentList @('/c', 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "{quoted_child}"') `
-WindowStyle Hidden `
-PassThru
try {{
$deadline = (Get-Date).AddSeconds(10)
while (-not (Test-Path -LiteralPath '{quoted_child_pid_for_harness}') -and (Get-Date) -lt $deadline) {{
Start-Sleep -Milliseconds 100
}}
if (-not (Test-Path -LiteralPath '{quoted_child_pid_for_harness}')) {{ exit 2 }}
$childProcessId = [int](Get-Content -LiteralPath '{quoted_child_pid_for_harness}' -Raw)
$stopped = @(Stop-ProcessTreeBounded -RootProcessId $root.Id -TimeoutSec 10 -Role 'test tree')
if ($null -ne (Get-Process -Id $root.Id -ErrorAction SilentlyContinue)) {{ exit 3 }}
if ($null -ne (Get-Process -Id $childProcessId -ErrorAction SilentlyContinue)) {{ exit 4 }}
exit 0
}} finally {{
Stop-Process -Id $root.Id -Force -ErrorAction SilentlyContinue
}}
''',
encoding="utf-8-sig",
)
completed = subprocess.run(
[
powershell,
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
str(harness),
],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=30,
check=False,
)
self.assertEqual(
completed.returncode,
0,
msg=f"stdout={completed.stdout}\nstderr={completed.stderr}",
)
def test_public_provider_model_and_loopback_environment_are_explicit(self) -> None:
for expected in (
'$WhisperModel = "small"',