From ae295dc308ad4482eed0ca9faca7e6d0dc9bca63 Mon Sep 17 00:00:00 2001 From: EJClaw Date: Fri, 25 Sep 2026 22:50:08 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=AC=B4=EC=9D=B8=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EB=B9=84=EC=A0=80=EB=8B=9D=EC=9D=B4=20=ED=86=B5=EC=A7=B8?= =?UTF-8?q?=EB=A1=9C=20=EC=95=88=20=EB=8F=8C=EB=8D=98=20=EC=9D=B8=EC=BD=94?= =?UTF-8?q?=EB=94=A9/=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EA=B2=B0=ED=95=A8=203=EA=B1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실제 설치에서 드러난 것들이다. 셋 다 증상이 원인과 전혀 안 닮아서 기록해 둔다. 1) provision.ps1 이 1행부터 파싱 실패 - 'Missing closing }' Windows PowerShell 5.1 은 BOM 없는 .ps1 을 ANSI(CP1252)로 읽는다. 한글 주석의 UTF-8 바이트가 CP1252 에서 스마트따옴표(0x93/0x94 = " ")로 변하고, 파서가 그걸 문자열 시작으로 보고 뒤를 전부 삼킨다. -> build.sh 가 ISO 에 넣을 때 UTF-8 BOM 을 붙인다. 2) stage2.ps1 의 1행이 주석으로 인식되지 않음 agent 가 받은 본문에 BOM 이 있으면 Set-Content -Encoding UTF8 이 BOM 을 한 번 더 붙여 BOM 이 두 개가 된다. PowerShell 은 첫 개만 벗겨내고 남은 U+FEFF 때문에 '#' 이 줄 맨 앞이 아니게 된다. -> agent 가 TrimStart(U+FEFF), 서버는 charset=utf-8 을 명시한다. 3) Git 설치가 'Cannot convert System.Object[] to System.String' 로 실패 Log 가 Write-Output 이라 함수 안에서 부르면 그 함수의 반환값에 로그가 섞인다. Get-File 이 배열을 돌려주니 Start-Process 가 죽었다. -> Log 를 Write-Host 로 바꿨다. 덤으로 시리얼/HTTP 로그의 한글이 '?' 로 깨지던 것도 고쳤다 (SerialPort 기본 인코딩이 ASCII). 그리고 러너 토큰이 없어도 워크플로와 같은 단계를 VM 안에서 직접 돌리는 검증 절차를 stage2 에 붙였다. --- scripts/win-ci/build.sh | 13 +++- scripts/win-ci/serve-host.py | 8 +++ scripts/win-ci/serve/stage2.ps1 | 69 ++++++++++++++++++++- scripts/win-ci/type.sh | 67 ++++++++++++++++++++ scripts/win-ci/unattend/setup/agent.ps1 | 18 +++++- scripts/win-ci/unattend/setup/provision.ps1 | 2 + 6 files changed, 172 insertions(+), 5 deletions(-) create mode 100755 scripts/win-ci/type.sh diff --git a/scripts/win-ci/build.sh b/scripts/win-ci/build.sh index 7133971..7ace5d0 100755 --- a/scripts/win-ci/build.sh +++ b/scripts/win-ci/build.sh @@ -37,7 +37,18 @@ fetch "$VIRTIO_URL" "$D/iso/virtio-win.iso" 500 # 무인 설치 미디어. Windows Setup 이 붙어 있는 모든 드라이브 루트에서 # autounattend.xml 을 찾으므로 CD 로 물려주면 된다. cp "$HERE/unattend/autounattend.xml" "$D/unattend-root/" -cp "$HERE/unattend/setup/"*.ps1 "$D/unattend-root/setup/" + +# ⚠️ .ps1 은 반드시 UTF-8 BOM 으로 넣는다. +# Windows PowerShell 5.1 은 BOM 이 없으면 현재 ANSI 코드페이지(en-US -> CP1252)로 +# 읽는다. 한글 주석의 UTF-8 바이트가 CP1252 에서 스마트따옴표(“ ” ‘)로 변하고, +# PowerShell 은 그걸 문자열 시작으로 보고 스크립트 뒤를 전부 삼킨다. 증상은 +# 엉뚱하게도 "Missing closing '}' in statement block" 이다. 실제로 이것 때문에 +# 첫 설치의 provision.ps1 이 통째로 안 돌았다. +for f in "$HERE/unattend/setup/"*.ps1; do + out="$D/unattend-root/setup/$(basename "$f")" + printf '\xef\xbb\xbf' > "$out" + cat "$f" >> "$out" +done xorriso -as mkisofs -V UNATTEND -J -joliet-long -R -o "$D/unattend.iso" "$D/unattend-root/" # 호스트 제어 채널이 내려줄 스크립트 diff --git a/scripts/win-ci/serve-host.py b/scripts/win-ci/serve-host.py index 43b7c7f..2975cec 100755 --- a/scripts/win-ci/serve-host.py +++ b/scripts/win-ci/serve-host.py @@ -27,6 +27,14 @@ class Handler(http.server.SimpleHTTPRequestHandler): def __init__(self, *a, **kw): super().__init__(*a, directory=ROOT, **kw) + def guess_type(self, path): + # .ps1 은 charset 을 반드시 명시한다. 없으면 PowerShell 쪽에서 + # ISO-8859-1 로 디코딩해 한글 주석이 깨지고, 깨진 바이트가 CP1252 의 + # 스마트따옴표가 되어 파서가 스크립트를 통째로 삼킨다. + if str(path).endswith(".ps1"): + return "text/plain; charset=utf-8" + return super().guess_type(path) + def do_GET(self): if self.path.rstrip("/") == "/ping": body = b"pong\n" diff --git a/scripts/win-ci/serve/stage2.ps1 b/scripts/win-ci/serve/stage2.ps1 index 4213fe9..201b54c 100644 --- a/scripts/win-ci/serve/stage2.ps1 +++ b/scripts/win-ci/serve/stage2.ps1 @@ -13,7 +13,11 @@ $ErrorActionPreference = 'Stop' $Dl = 'C:\win-ci\dl' New-Item -ItemType Directory -Force -Path $Dl | Out-Null -function Log($m) { Write-Output "$m" } +# Write-Output 를 쓰면 안 된다. 함수 안에서 부르면 그 함수의 반환값에 +# 로그 문자열이 섞여 들어가 $f 가 배열이 되고, Start-Process 가 +# "Cannot convert System.Object[] to System.String" 으로 죽는다. +# 실제로 이것 때문에 Git 설치가 실패했다. +function Log($m) { Write-Host "$m" } function Get-File($url, $name) { $out = Join-Path $Dl $name @@ -195,3 +199,66 @@ try { Log "화면 열거 실패: $($_.Exception.Message)" } Log "=== stage2 완료 ===" + +# ================================================================== +# Windows 전용 경로 실제 검증 +# +# 러너 등록 토큰이 없어도 여기서 워크플로와 똑같은 단계를 직접 돌린다. +# 이게 VM 을 만든 진짜 이유다 - 리눅스에서 못 돌리던 경로(WASAPI, 전역 +# 단축키, 모니터 열거, PyInstaller exe)가 실제로 통과하는지 본다. +# +# 통째로 try/catch 로 감싼다. 검증이 실패해도 stage2 자체는 성공이어야 +# agent 가 무한 재시도에 빠지지 않는다. 결과는 로그로 남는다. +# ================================================================== +$Repo = 'C:\win-ci\live-app-translator' +function Step($name, $block) { + Log "--- [$name] 시작" + try { + & $block + Log "--- [$name] 통과" + return $true + } catch { + Log "--- [$name] 실패: $($_.Exception.Message)" + return $false + } +} + +$ErrorActionPreference = 'Continue' +try { + Sync-Path + if (Test-Path $Repo) { + Log "저장소 갱신" + Push-Location $Repo; & git fetch --all -q; & git reset --hard origin/main -q; Pop-Location + } else { + Log "저장소 clone" + & git clone -q https://git.tkrmagid.kr/tkrmagid/live-app-translator $Repo + } + + Push-Location $Repo + $py = Join-Path $Repo '.venv-ci\Scripts\python.exe' + if (-not (Test-Path $py)) { & python -m venv .venv-ci } + & $py -m pip install --upgrade pip --quiet + & $py -m pip install -r packaging\requirements-portable.txt --quiet + & $py -m pip install pytest ruff --quiet + + # 워크플로와 같은 조건인지 - torch 가 없어야 "포터블에서 1~3티어가 된다"를 + # 실제로 검증한 셈이 된다. + & $py -c "import importlib.util,sys; sys.exit(0 if importlib.util.find_spec('torch') is None else 1)" + Log "포터블 조건(torch 없음) = $($LASTEXITCODE -eq 0)" + + $env:PYTHONPATH = 'src' + $r = @{} + $r['pytest'] = Step 'pytest' { & $py -m pytest tests -q 2>&1 | Select-Object -Last 8 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } } + $r['ruff'] = Step 'ruff' { & $py -m ruff check src tests scripts 2>&1 | Select-Object -Last 5 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } } + $r['windows경로'] = Step 'windows_smoke' { & $py packaging\windows_smoke.py 2>&1 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } } + $r['exe빌드'] = Step 'pyinstaller' { & $py -m pip install pyinstaller --quiet; & $py -m PyInstaller packaging\livesub.spec --noconfirm 2>&1 | Select-Object -Last 4 | ForEach-Object { Log " $_" }; if (-not (Test-Path 'dist\LiveSub.exe')) { throw 'dist\LiveSub.exe 없음' }; Log " exe 크기 $([math]::Round((Get-Item 'dist\LiveSub.exe').Length/1MB,1)) MB" } + $r['exe기동'] = Step 'exe 실행' { & $py packaging\windows_smoke.py --exe dist\LiveSub.exe 2>&1 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } } + + Log "=== Windows 검증 결과 ===" + foreach ($k in $r.Keys) { Log ("{0,-14} {1}" -f $k, $(if ($r[$k]) { 'PASS' } else { 'FAIL' })) } + Log "=== 검증 끝 ===" + Pop-Location +} catch { + Log "검증 준비 단계에서 실패: $($_.Exception.Message)" + try { Pop-Location } catch {} +} diff --git a/scripts/win-ci/type.sh b/scripts/win-ci/type.sh new file mode 100755 index 0000000..a1bb390 --- /dev/null +++ b/scripts/win-ci/type.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# VM 게스트에 키보드 입력을 보낸다 (QMP send-key). +# +# ./type.sh text "hello world" 문자열 입력 +# ./type.sh key ret 단일 키 (qcode) +# ./type.sh key alt-f4 조합키 (하이픈으로 연결) +# +# 게스트에 네트워크가 없을 때 유일한 제어 수단이므로 지우지 말 것. +set -euo pipefail +python3 - "$@" <<'PY' +import json, socket, sys, time + +MODE = sys.argv[1] +ARG = sys.argv[2] + +s = socket.socket(socket.AF_UNIX); s.settimeout(30) +s.connect("/home/claude/win-ci/qmp.sock"); f = s.makefile("rwb"); f.readline() + +def cmd(o): + f.write((json.dumps(o) + "\n").encode()); f.flush() + while True: + m = json.loads(f.readline()) + if "return" in m or "error" in m: + if "error" in m: print("ERR", m, file=sys.stderr) + return m + +cmd({"execute": "qmp_capabilities"}) + +def send(qcodes): + cmd({"execute": "send-key", "arguments": { + "keys": [{"type": "qcode", "data": q} for q in qcodes]}}) + time.sleep(0.04) + +SHIFTED = { + '!': '1', '@': '2', '#': '3', '$': '4', '%': '5', '^': '6', '&': '7', + '*': '8', '(': '9', ')': '0', '_': 'minus', '+': 'equal', '{': 'bracket_left', + '}': 'bracket_right', '|': 'backslash', ':': 'semicolon', '"': 'apostrophe', + '<': 'comma', '>': 'dot', '?': 'slash', '~': 'grave_accent', +} +PLAIN = { + ' ': 'spc', '-': 'minus', '=': 'equal', '[': 'bracket_left', + ']': 'bracket_right', '\\': 'backslash', ';': 'semicolon', + "'": 'apostrophe', ',': 'comma', '.': 'dot', '/': 'slash', + '`': 'grave_accent', '\n': 'ret', '\t': 'tab', +} + +def char_keys(c): + if c.isalpha(): + q = c.lower() + return ['shift', q] if c.isupper() else [q] + if c.isdigit(): + return [c] + if c in SHIFTED: + return ['shift', SHIFTED[c]] + if c in PLAIN: + return [PLAIN[c]] + raise SystemExit("unsupported char: %r" % c) + +if MODE == "text": + for c in ARG: + send(char_keys(c)) +elif MODE == "key": + send(ARG.split("-")) +else: + raise SystemExit("usage: type.sh text | type.sh key ") +print("ok") +PY diff --git a/scripts/win-ci/unattend/setup/agent.ps1 b/scripts/win-ci/unattend/setup/agent.ps1 index 120f2f2..2f97fd6 100644 --- a/scripts/win-ci/unattend/setup/agent.ps1 +++ b/scripts/win-ci/unattend/setup/agent.ps1 @@ -24,10 +24,13 @@ function Say([string]$m) { try { Add-Content -Path $LogFile -Value $line -Encoding UTF8 } catch {} try { $p = New-Object System.IO.Ports.SerialPort 'COM1', 115200, 'None', 8, 'One' + # 기본 인코딩이 ASCII 라 한글이 전부 '?' 가 된다 + $p.Encoding = [Text.Encoding]::UTF8 $p.Open(); $p.WriteLine($line); $p.Close() } catch {} try { - Invoke-RestMethod -Uri "$Host_/log" -Method Post -Body $line -TimeoutSec 5 | Out-Null + Invoke-RestMethod -Uri "$Host_/log" -Method Post -TimeoutSec 5 ` + -Body ([Text.Encoding]::UTF8.GetBytes($line)) -ContentType "text/plain; charset=utf-8" | Out-Null } catch {} } @@ -51,8 +54,17 @@ else { Say "ERROR 5분 동안 호스트에 못 붙었다. virtio-net 드 # 이후로는 계속 돌면서 stage2 가 바뀔 때마다 실행한다. while ($true) { try { - $body = (Invoke-WebRequest -Uri "$Host_/stage2.ps1" -TimeoutSec 20 -UseBasicParsing).Content - if ($body -is [byte[]]) { $body = [Text.Encoding]::UTF8.GetString($body) } + # 반드시 바이트로 받아 UTF-8 로 직접 디코딩한다. Invoke-WebRequest 의 + # .Content 는 Content-Type 에 charset 이 없으면 ISO-8859-1 로 디코딩해 + # 한글 주석이 깨지고, 깨진 바이트가 CP1252 의 스마트따옴표(0x93/0x94)가 + # 되어 PowerShell 파서가 그 뒤를 전부 문자열로 삼킨다. 실제로 겪은 사고다. + $wc = New-Object Net.WebClient + $body = [Text.Encoding]::UTF8.GetString($wc.DownloadData("$Host_/stage2.ps1")) + # 받은 쪽에 BOM 이 있으면 문자열 맨 앞에 U+FEFF 가 남는다. 아래에서 + # Set-Content -Encoding UTF8 이 BOM 을 또 붙이므로 BOM 이 두 개가 되고, + # PowerShell 은 첫 개만 벗겨내 남은 U+FEFF 때문에 1행이 주석으로 + # 인식되지 않는다. 그래서 여기서 반드시 떼어낸다. + $body = $body.TrimStart([char]0xFEFF) $sha = [BitConverter]::ToString( [Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($body)) diff --git a/scripts/win-ci/unattend/setup/provision.ps1 b/scripts/win-ci/unattend/setup/provision.ps1 index 369ae45..5e533d7 100644 --- a/scripts/win-ci/unattend/setup/provision.ps1 +++ b/scripts/win-ci/unattend/setup/provision.ps1 @@ -16,6 +16,8 @@ New-Item -ItemType Directory -Force -Path 'C:\win-ci' | Out-Null $script:Port = $null try { $script:Port = New-Object System.IO.Ports.SerialPort 'COM1', 115200, 'None', 8, 'One' + # 기본 인코딩이 ASCII 라 한글이 전부 '?' 가 된다 + $script:Port.Encoding = [Text.Encoding]::UTF8 $script:Port.Open() } catch { $script:Port = $null }