1
0
Fork 0

feat(tdd): implement SSOT canon, AGENTS.md, ultra-strict design audit, E2E scenarios, and Forgejo release pipeline
Some checks are pending
Forgejo Release / build-and-release (push) Waiting to run

This commit is contained in:
Yun Chan 2026-09-03 12:21:40 +09:00
parent 24aa70ae32
commit 4cd9c678c0
25 changed files with 1684 additions and 238 deletions

View file

@ -71,6 +71,12 @@ public sealed record ConvertOptions
/// <summary>독립(Independent) 배치 변환의 최대 병렬 수. 기본 = 논리 코어 수. 미디어(FFmpeg) 위주 배치는 낮춰 오버서브스크립션 회피.</summary>
public int BatchParallelism { get; init; } = Environment.ProcessorCount;
/// <summary>
/// LibreOffice(soffice) 변환 1건의 타임아웃(초). 초과 시 프로세스 트리를 강제 종료해 hang을 회수한다.
/// 특정 HWP/문서에서 soffice가 무한 대기하는 사례를 방지(기본 120초). 큰 문서가 많으면 늘린다.
/// </summary>
public int LibreOfficeTimeoutSeconds { get; init; } = 120;
/// <summary>영상 인코딩 시 GPU 하드웨어 가속(NVENC)을 우선 시도하고, 실패하면 CPU로 자동 폴백한다.</summary>
public bool VideoPreferGpu { get; init; } = true;

View file

@ -1,4 +1,3 @@
using System.Diagnostics;
using System.Text;
using Everything2Everything.Core.Providers;
using Markdig;
@ -241,37 +240,10 @@ public sealed class DocumentProvider : IConverterProvider
throw new InvalidOperationException("LibreOffice를 찾을 수 없습니다.");
var outDir = Path.GetDirectoryName(Path.GetFullPath(targetPath))!;
Directory.CreateDirectory(outDir);
var psi = new ProcessStartInfo
{
FileName = soffice,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
psi.ArgumentList.Add("--headless");
psi.ArgumentList.Add("--norestore");
psi.ArgumentList.Add("--nofirststartwizard");
psi.ArgumentList.Add("--convert-to");
psi.ArgumentList.Add(outputFormat);
psi.ArgumentList.Add("--outdir");
psi.ArgumentList.Add(outDir);
psi.ArgumentList.Add(sourcePath);
using var proc = Process.Start(psi)
?? throw new InvalidOperationException("LibreOffice 프로세스 시작 실패");
try { await proc.WaitForExitAsync(ct).ConfigureAwait(false); }
catch (OperationCanceledException) { try { proc.Kill(true); } catch { } throw; }
if (proc.ExitCode != 0)
throw new InvalidOperationException($"LibreOffice 변환 실패 (exit {proc.ExitCode})");
var produced = Path.Combine(outDir, Path.GetFileNameWithoutExtension(sourcePath) + "." + outputFormat);
if (!File.Exists(produced))
throw new FileNotFoundException("LibreOffice가 결과물을 생성하지 않았습니다.", produced);
// soffice 호출 직렬화(기본 프로필 락 충돌 방지) + 타임아웃/트리 kill + 출력 검증은 LibreOfficeRunner가 담당.
var produced = await LibreOfficeRunner.ConvertAsync(
soffice, sourcePath, outDir, outputFormat, LibreOfficeRunner.DefaultTimeoutSeconds, ct)
.ConfigureAwait(false);
if (!string.Equals(produced, targetPath, StringComparison.OrdinalIgnoreCase))
{

View file

@ -1,4 +1,3 @@
using System.Diagnostics;
using Everything2Everything.Core.Providers;
namespace Everything2Everything.Core.Converters;
@ -68,19 +67,28 @@ public sealed class HwpxProvider : IConverterProvider
return ConvertResult.Fail(sourcePath, "LibreOffice가 필요합니다.");
var outExt = ConversionPair.Normalize(outputExtension);
var tempPdf = Path.Combine(Path.GetTempPath(),
$"e2e_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.pdf");
// 변환마다 고유 작업폴더 — soffice 출력 파일명(입력 베이스명)이 다른 변환과 outdir에서 충돌하지 않게.
var workDir = Path.Combine(Path.GetTempPath(), $"e2e_hwp_{Guid.NewGuid():N}");
Directory.CreateDirectory(workDir);
try
{
progress?.Report(0.05);
var converted = await ConvertWithLibreOfficeAsync(soffice, sourcePath, tempPdf, cancellationToken)
.ConfigureAwait(false);
if (!converted)
string producedPdf;
try
{
// soffice 호출 직렬화 + 타임아웃/프로세스 트리 kill + 출력 검증은 LibreOfficeRunner가 담당한다.
producedPdf = await LibreOfficeRunner.ConvertAsync(
soffice, sourcePath, workDir, "pdf", options.LibreOfficeTimeoutSeconds, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
return ConvertResult.Fail(sourcePath,
"LibreOffice 변환에 실패했습니다. H2Orestart 확장이 정상 설치되어 있는지 확인하세요.");
"LibreOffice 변환에 실패했습니다. H2Orestart 확장과 Java(JRE)가 정상 설치되어 있는지 확인하세요. " + ex.Message, ex);
}
progress?.Report(0.55);
@ -90,64 +98,18 @@ public sealed class HwpxProvider : IConverterProvider
var finalPath = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, ".pdf", options.OnCollision);
if (OutputPathHelper.ShouldSkip(finalPath, options.OnCollision))
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
File.Copy(tempPdf, finalPath, overwrite: options.OnCollision == NameCollision.Overwrite);
File.Copy(producedPdf, finalPath, overwrite: options.OnCollision == NameCollision.Overwrite);
progress?.Report(1.0);
return ConvertResult.Ok(sourcePath, new[] { finalPath });
}
var inner = new Progress<double>(p => progress?.Report(0.55 + p * 0.45));
return _pdfProvider.ConvertCore(tempPdf, outputDirectory, outExt, options, inner, cancellationToken)
return _pdfProvider.ConvertCore(producedPdf, outputDirectory, outExt, options, inner, cancellationToken)
with { SourcePath = sourcePath };
}
finally
{
try { if (File.Exists(tempPdf)) File.Delete(tempPdf); } catch { }
try { Directory.Delete(workDir, recursive: true); } catch { }
}
}
private static async Task<bool> ConvertWithLibreOfficeAsync(string sofficePath, string sourcePath, string targetPdf, CancellationToken ct)
{
var outDir = Path.GetDirectoryName(targetPdf)!;
var psi = new ProcessStartInfo
{
FileName = sofficePath,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
psi.ArgumentList.Add("--headless");
psi.ArgumentList.Add("--norestore");
psi.ArgumentList.Add("--nofirststartwizard");
psi.ArgumentList.Add("--convert-to");
psi.ArgumentList.Add("pdf");
psi.ArgumentList.Add("--outdir");
psi.ArgumentList.Add(outDir);
psi.ArgumentList.Add(sourcePath);
using var proc = Process.Start(psi);
if (proc is null) return false;
try
{
await proc.WaitForExitAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
try { proc.Kill(true); } catch { }
throw;
}
if (proc.ExitCode != 0) return false;
var produced = Path.Combine(outDir, Path.GetFileNameWithoutExtension(sourcePath) + ".pdf");
if (!File.Exists(produced)) return false;
if (!string.Equals(produced, targetPdf, StringComparison.OrdinalIgnoreCase))
{
if (File.Exists(targetPdf)) File.Delete(targetPdf);
File.Move(produced, targetPdf);
}
return File.Exists(targetPdf);
}
}

View file

@ -0,0 +1,119 @@
using System.Diagnostics;
namespace Everything2Everything.Core.Converters;
/// <summary>
/// soffice(LibreOffice) <c>--headless --convert-to</c> 호출을 단일 지점으로 집약한다.
/// HWP/HWPX 등 모든 LibreOffice 경유 변환(HwpxProvider·DocumentProvider)이 이 헬퍼를 통과한다.
///
/// 2026 리서치·적대적 검증으로 확정된 두 결함을 한 곳에서 방어한다:
/// <list type="number">
/// <item><b>직렬화 게이트</b> — LibreOffice는 프로필당 단일 인스턴스 설계(~.lock)다. 기본 프로필을 공유한 채
/// 여러 soffice를 동시에 spawn하면 둘째 이후 프로세스가 첫 인스턴스에 위임되어 조용히 실패/멈춘다
/// (freedesktop Bug 106134/82775). 정적 <see cref="SemaphoreSlim"/>으로 soffice 호출을 직렬화해 락 충돌을 0으로 만든다.
/// (이미지 등 다른 Provider의 병렬성은 이 경로를 통과하지 않으므로 영향받지 않는다.)</item>
/// <item><b>타임아웃 + 프로세스 트리 kill</b> — 특정 문서에서 soffice가 무한 hang하는 사례가 다수 보고된다.
/// 타임아웃 초과 시 <see cref="Process.Kill(bool)"/>로 자식까지 종료해 배치 전체가 멈추는 사고를 회수한다.</item>
/// </list>
/// 성공은 종료코드뿐 아니라 <b>출력 파일 존재</b>로 검증한다(조용한 스킵이 잘못된 결과로 둔갑하지 않게).
/// </summary>
internal static class LibreOfficeRunner
{
// 기본 프로필 락 충돌 방지: soffice 호출을 프로세스 전역에서 직렬화한다.
private static readonly SemaphoreSlim Gate = new(1, 1);
public const int DefaultTimeoutSeconds = 120;
/// <summary>
/// <paramref name="sourcePath"/>를 <paramref name="outputFormat"/>(예: "pdf", "html", "docx", "txt")으로
/// 변환해 <paramref name="outDir"/>에 쓰고, 생성된 결과 파일의 전체 경로를 반환한다.
/// 결과 파일명은 LibreOffice 규칙상 <c>{입력 베이스명}.{outputFormat}</c>이다. 실패 시 예외를 던진다.
/// 출력 파일명 충돌을 피하려면 호출측이 변환마다 고유한 <paramref name="outDir"/>를 넘길 것.
/// </summary>
public static async Task<string> ConvertAsync(
string sofficePath,
string sourcePath,
string outDir,
string outputFormat,
int timeoutSeconds,
CancellationToken ct)
{
Directory.CreateDirectory(outDir);
var seconds = timeoutSeconds <= 0 ? DefaultTimeoutSeconds : timeoutSeconds;
await Gate.WaitAsync(ct).ConfigureAwait(false);
try
{
var psi = new ProcessStartInfo
{
FileName = sofficePath,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
psi.ArgumentList.Add("--headless");
psi.ArgumentList.Add("--norestore");
psi.ArgumentList.Add("--nofirststartwizard");
psi.ArgumentList.Add("--convert-to");
psi.ArgumentList.Add(outputFormat);
psi.ArgumentList.Add("--outdir");
psi.ArgumentList.Add(outDir);
psi.ArgumentList.Add(sourcePath);
using var proc = Process.Start(psi)
?? throw new InvalidOperationException("LibreOffice 프로세스를 시작하지 못했습니다.");
// 파이프 버퍼가 가득 차 soffice가 블록되는 것을 막기 위해 두 스트림을 비동기로 비운다.
// (프로세스가 종료/강제종료되면 스트림 EOF로 두 태스크 모두 완료된다.)
var stdErrTask = proc.StandardError.ReadToEndAsync();
var stdOutTask = proc.StandardOutput.ReadToEndAsync();
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(seconds));
try
{
await proc.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
try { proc.Kill(entireProcessTree: true); } catch { /* 이미 종료됨 */ }
if (ct.IsCancellationRequested)
throw; // 사용자 취소 — 위로 전파
throw new TimeoutException(
$"LibreOffice 변환이 {seconds}초를 초과해 중단했습니다: {Path.GetFileName(sourcePath)}");
}
if (proc.ExitCode != 0)
{
var err = await SafeReadAsync(stdErrTask).ConfigureAwait(false);
var detail = string.IsNullOrWhiteSpace(err) ? "" : " " + err.Trim();
throw new InvalidOperationException(
$"LibreOffice 변환 실패 (exit {proc.ExitCode}).{detail}");
}
// 출력 스트림은 정상 경로에서 굳이 쓰지 않지만, 버퍼가 비워지도록 마저 완료시킨다.
_ = await SafeReadAsync(stdOutTask).ConfigureAwait(false);
var produced = Path.Combine(outDir,
Path.GetFileNameWithoutExtension(sourcePath) + "." + outputFormat);
if (!File.Exists(produced))
throw new FileNotFoundException(
"LibreOffice가 결과물을 생성하지 않았습니다. 한글 입력이면 H2Orestart 확장과 Java(JRE) 설치를 확인하세요.",
produced);
return produced;
}
finally
{
Gate.Release();
}
}
private static async Task<string> SafeReadAsync(Task<string> readTask)
{
try { return await readTask.ConfigureAwait(false); }
catch { return ""; }
}
}