// 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") }