Files

108 lines
3.3 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package ocr
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"strings"
"syscall"
"time"
)
// bounded_buffer never retains more than limit bytes. Child-output overflow
// cancels its process immediately instead of leaving a stopped pipe reader.
type bounded_buffer struct {
data bytes.Buffer
limit int
ctx context.Context
cancel context.CancelFunc
overflow bool
}
func (buffer *bounded_buffer) Write(data []byte) (int, error) {
if err := buffer.ctx.Err(); err != nil {
return 0, err
}
if len(data) > buffer.limit-buffer.Len() {
buffer.overflow = true
if buffer.cancel != nil {
buffer.cancel()
}
return 0, ErrLimit
}
return buffer.data.Write(data)
}
func (buffer *bounded_buffer) Bytes() []byte { return buffer.data.Bytes() }
func (buffer *bounded_buffer) Len() int { return buffer.data.Len() }
func (engine *Engine) run(parent context.Context, input []byte, limit int, args ...string) ([]byte, []byte, error) {
return run_process(parent, engine.command, input, limit, args...)
}
func run_process(parent context.Context, factory func(context.Context, ...string) *exec.Cmd, input []byte, limit int, args ...string) ([]byte, []byte, error) {
ctx, cancel := context.WithCancel(parent)
defer cancel()
command := factory(ctx, args...)
// This reader targets Linux/Xorg. Own the whole process group, including
// descendants that might inherit pipes from a wrapper executable.
command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
kill := func() error {
if command.Process == nil {
return os.ErrProcessDone
}
err := syscall.Kill(-command.Process.Pid, syscall.SIGKILL)
if errors.Is(err, syscall.ESRCH) {
return os.ErrProcessDone
}
return err
}
command.Cancel = kill
command.WaitDelay = time.Second
command.Env = process_environment(command.Env)
command.Stdin = bytes.NewReader(input)
output := &bounded_buffer{limit: limit, ctx: ctx, cancel: cancel}
diagnostics := &bounded_buffer{limit: maxStderrBytes, ctx: ctx, cancel: cancel}
command.Stdout, command.Stderr = output, diagnostics
err := command.Run()
// WaitDelay closes inherited pipes after a bound, but os/exec only kills the
// direct child then. Retire any remaining group before admitting another job.
if command.Process != nil {
_ = kill()
}
if parent.Err() != nil {
return nil, nil, parent.Err()
}
if output.overflow || diagnostics.overflow {
return nil, nil, ErrLimit
}
if err != nil {
if errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) {
return nil, nil, ErrUnavailable
}
// Never forward native stderr: it can contain captured text or paths.
return nil, diagnostics.Bytes(), ErrRecognition
}
return output.Bytes(), diagnostics.Bytes(), nil
}
func process_environment(environment []string) []string {
if environment == nil {
environment = os.Environ()
}
result := make([]string, 0, len(environment)+2)
for _, entry := range environment {
if !strings.HasPrefix(entry, "LC_ALL=") && !strings.HasPrefix(entry, "OMP_THREAD_LIMIT=") {
result = append(result, entry)
}
}
// Stable CLI diagnostics; avoid using every core for a desktop OCR request.
return append(result, "LC_ALL=C", "OMP_THREAD_LIMIT=1")
}