Files
nexarch/dms/internal/pdfconvert/convert.go
T
patrickandClaude Sonnet 5 5ed5b32b57 DOC-06: waitdelay gegen pipe-hang bei zeitlimit-ueberschreitung
Realer Testfund auf 192.168.1.131: ein Kindprozess, der seinerseits (ohne
exec) einen Enkelprozess startet, haelt nach SIGKILL des direkten Kindes
dessen Ausgabe-Pipe weiter offen - cmd.Wait() blockiert dann trotz
abgelaufenem Kontext unbegrenzt. cmd.WaitDelay (Go 1.20+) begrenzt das.
Testfixture (hanging-soffice.sh) ebenfalls auf "exec sleep" korrigiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 13:11:08 +02:00

189 lines
6.7 KiB
Go

package pdfconvert
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/jackc/pgx/v5/pgxpool"
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/storage"
)
const defaultTimeout = 60 * time.Second
// Generator konvertiert Office-Formate zu PDF ueber einen headless
// LibreOffice-Prozess. SofficePath und Timeout sind injizierbar (Tests,
// abweichende Installationspfade). Jeder Aufruf bekommt ein eigenes,
// frisches Profilverzeichnis (-env:UserInstallation) — ohne das teilen sich
// gleichzeitig laufende soffice-Prozesse eine Profil-Sperre und blockieren
// sich gegenseitig, was Pflichtpruefung 3 (Absturz eines Jobs darf andere
// nicht beeintraechtigen) verletzen wuerde.
type Generator struct {
Pool *pgxpool.Pool
Storage *storage.Service
Crypto *dmscrypto.Service
TenantSlug string
SofficePath string
Timeout time.Duration
}
func (g *Generator) sofficeBinary() string {
if g.SofficePath != "" {
return g.SofficePath
}
return "soffice"
}
func (g *Generator) timeout() time.Duration {
if g.Timeout > 0 {
return g.Timeout
}
return defaultTimeout
}
// Handler ist der jobqueue.Handler fuer JobType — folgt demselben Muster
// wie thumbnail.Generator.Handler (DOC-04): dekodieren, "processing"
// markieren, konvertieren, Ergebnis oder Fehlerzustand speichern. Ein
// zurueckgegebener Fehler laesst FDN-04 die Job-Queue-Wiederholungslogik
// greifen; andere Jobs bleiben durch SKIP LOCKED in Queue.Dequeue
// unberuehrt (Pflichtpruefung 3).
func (g *Generator) Handler(ctx context.Context, job *jobqueue.Job) error {
var payload jobPayload
if err := json.Unmarshal(job.Payload, &payload); err != nil {
return fmt.Errorf("pdfconvert: auftrag dekodieren: %w", err)
}
if err := markProcessing(ctx, g.Pool, payload.RevisionID); err != nil {
return fmt.Errorf("pdfconvert: verarbeitung markieren: %w", err)
}
if err := g.process(ctx, payload); err != nil {
if markErr := markFailed(ctx, g.Pool, payload.RevisionID, err); markErr != nil {
return fmt.Errorf("pdfconvert: fehlschlag erfassen (ursprünglicher fehler: %v): %w", err, markErr)
}
return err
}
return nil
}
func (g *Generator) process(ctx context.Context, payload jobPayload) error {
var storageKey, mimeType string
var wrappedDEK []byte
if err := g.Pool.QueryRow(ctx, `
SELECT storage_key, mime_type, wrapped_dek FROM file_revisions WHERE id = $1
`, payload.RevisionID).Scan(&storageKey, &mimeType, &wrappedDEK); err != nil {
return fmt.Errorf("pdfconvert: revision lesen: %w", err)
}
format, ok := lookupFormat(mimeType)
if !ok {
return fmt.Errorf("pdfconvert: nicht unterstuetztes quellformat %q", mimeType)
}
cipherReader, err := g.Storage.Get(ctx, storageKey)
if err != nil {
return fmt.Errorf("pdfconvert: original aus storage laden: %w", err)
}
defer func() { _ = cipherReader.Close() }()
plainReader, err := g.Crypto.Open(ctx, g.TenantSlug, wrappedDEK, cipherReader)
if err != nil {
return fmt.Errorf("pdfconvert: original entschluesseln: %w", err)
}
plaintext, err := io.ReadAll(plainReader)
if err != nil {
return fmt.Errorf("pdfconvert: original lesen: %w", err)
}
pdfBytes, err := g.convertToPDF(ctx, plaintext, format)
if err != nil {
return err
}
env, err := g.Crypto.Seal(ctx, g.TenantSlug, bytes.NewReader(pdfBytes))
if err != nil {
return fmt.Errorf("pdfconvert: verschluesseln: %w", err)
}
ciphertext, err := io.ReadAll(env.Ciphertext)
if err != nil {
return fmt.Errorf("pdfconvert: chiffretext lesen: %w", err)
}
pdfKey := storage.ObjectKey(payload.DocumentID, payload.RevisionID) + "/office.pdf"
if _, err := g.Storage.Put(ctx, pdfKey, bytes.NewReader(ciphertext), int64(len(ciphertext)), "application/octet-stream"); err != nil {
return fmt.Errorf("pdfconvert: im storage ablegen: %w", err)
}
if err := markReady(ctx, g.Pool, payload.RevisionID, format.label, pdfKey, "application/pdf", env.WrappedDEK); err != nil {
return fmt.Errorf("pdfconvert: bereit-zustand speichern: %w", err)
}
return nil
}
// convertToPDF fuehrt die eigentliche Konvertierung isoliert und mit
// Zeitlimit aus (Akzeptanzkriterium 2). Isolation: eigenes Temp- und
// Profilverzeichnis je Aufruf, kein geteilter Zustand mit anderen
// gleichzeitig laufenden Konvertierungen. Zeitlimit: context.WithTimeout
// plus exec.CommandContext — ueberschreitet der Prozess die Frist, killt
// Go den Prozess automatisch und liefert einen Fehler statt den Job/Worker
// zu blockieren.
func (g *Generator) convertToPDF(ctx context.Context, input []byte, format officeFormat) ([]byte, error) {
tmpDir, err := os.MkdirTemp("", "dms-pdfconvert-*")
if err != nil {
return nil, fmt.Errorf("pdfconvert: temporaeres verzeichnis anlegen: %w", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
inputPath := filepath.Join(tmpDir, "input."+format.extension)
if err := os.WriteFile(inputPath, input, 0o600); err != nil {
return nil, fmt.Errorf("pdfconvert: eingabedatei schreiben: %w", err)
}
outDir := filepath.Join(tmpDir, "out")
if err := os.Mkdir(outDir, 0o700); err != nil {
return nil, fmt.Errorf("pdfconvert: ausgabeverzeichnis anlegen: %w", err)
}
profileDir := filepath.Join(tmpDir, "profile")
timeoutCtx, cancel := context.WithTimeout(ctx, g.timeout())
defer cancel()
cmd := exec.CommandContext(timeoutCtx, g.sofficeBinary(),
"--headless", "--norestore",
"-env:UserInstallation=file://"+profileDir,
"--convert-to", "pdf", "--outdir", outDir, inputPath,
)
// WaitDelay begrenzt eine bekannte exec.Cmd-Falle: stirbt der direkte
// Kindprozess, aber ein von ihm (ohne exec) gestarteter Enkelprozess
// haelt die Ausgabe-Pipe weiter offen, wartet Wait() sonst unbegrenzt
// auf deren Schliessung — trotz abgelaufenem Kontext. 5s zusaetzlich
// zum eigentlichen Zeitlimit reichen, um reguläre Prozessbeendigung
// abzuwarten, ohne das Zeitlimit selbst zu verwaessern.
cmd.WaitDelay = 5 * time.Second
output, runErr := cmd.CombinedOutput()
if timeoutCtx.Err() != nil {
return nil, fmt.Errorf("pdfconvert: zeitlimit (%s) ueberschritten: %w", g.timeout(), timeoutCtx.Err())
}
if runErr != nil {
return nil, fmt.Errorf("pdfconvert: soffice fehlgeschlagen: %w (ausgabe: %s)", runErr, string(output))
}
matches, err := filepath.Glob(filepath.Join(outDir, "*.pdf"))
if err != nil || len(matches) == 0 {
return nil, fmt.Errorf("pdfconvert: soffice lieferte keine pdf-ausgabedatei (matches=%v, err=%v, ausgabe: %s)", matches, err, string(output))
}
pdfBytes, err := os.ReadFile(matches[0])
if err != nil {
return nil, fmt.Errorf("pdfconvert: erzeugte pdf-datei lesen: %w", err)
}
return pdfBytes, nil
}