115 lines
5.8 KiB
Go
115 lines
5.8 KiB
Go
package runner
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"syscall"
|
|
"time"
|
|
|
|
"textmachine/platform/internal/ingest"
|
|
)
|
|
|
|
// build.go: the export channel — `tmctl build` run as a direct child, like the other $0 commands.
|
|
//
|
|
// It is shaped after bank-apply and not after readEngine for the same reason bank-apply is: the
|
|
// exit code is DATA here and not only a verdict. `build` has a refusal class of its own (16, a book
|
|
// with holes) and reaches the config class for arguments this side chose, so a caller that read
|
|
// every non-zero code as "the engine did not answer" could not tell a book it must not have asked
|
|
// for from a deployment that needs fixing.
|
|
//
|
|
// ⚠ NO `--keys-file`. `build` is $0 and key-less — it reads the store and writes a file, no
|
|
// provider is called — and the engine refuses the flag on every command but `translate` (D20.4: the
|
|
// $0 read verbs must not demand keys).
|
|
|
|
// BuildArgs is the verb's argv for ONE format written to a path of the platform's own choosing.
|
|
//
|
|
// ⚠ `--out` is not a convenience and the door may not drop it. Without it the engine writes beside
|
|
// its project database and then DELETES every format it was not asked for, from a set that belongs
|
|
// to the operator's own CLI builds (backend/internal/pipeline/bookbuild.go, the `RemovedFiles`
|
|
// loop) — so a user asking for `txt` would destroy the operator's `epub`, and two exports of one
|
|
// book would overwrite each other's artifact. With `--out` the engine does no housekeeping at all
|
|
// and each export is its own immutable file, which is also what makes the TTL and the GC this
|
|
// platform's to run.
|
|
//
|
|
// ⚠ `--partial` is ratified and not a taste (D39.178 п.1, owner 30.08): the door ALWAYS builds. A
|
|
// book with a hole comes back WITH the notice on its first page and a mark at every hole, never as
|
|
// a refusal handed to the reader. Refusing by default stays the operator's handle on the CLI.
|
|
func BuildArgs(workdir, format, out string) []string {
|
|
return []string{"build", "--config", filepath.Join(workdir, ConfigFile),
|
|
"--format", format, "--out", out, "--partial"}
|
|
}
|
|
|
|
// maxBuildReport bounds what is read back. The report is a page of counts plus two short lists of
|
|
// paths; the cap refuses a process at the configured path that is not the engine.
|
|
const maxBuildReport = 8 << 20
|
|
|
|
// buildStopGrace is how long the verb gets after SIGTERM before it is killed outright.
|
|
//
|
|
// ⚠ WHAT IT BUYS IS NOT WHAT AN EARLIER EDITION OF THIS COMMENT CLAIMED, and the correction came from
|
|
// an adversarial pass. It said the grace lets the process reach its own context check. `tmctl build`
|
|
// HAS no context check: `main.go` builds one with `signal.NotifyContext` and hands it to `status` and
|
|
// `translate`, and the `build` case is called without it (`backend/cmd/tmctl/main.go`, `case
|
|
// "build"`; `pipeline.BuildBook` takes no `context.Context` at all). So SIGTERM is caught by the
|
|
// runtime's handler and OBSERVED BY NOBODY: the build runs to completion, and what ends it at the
|
|
// caller's deadline is the SIGKILL after this grace.
|
|
//
|
|
// It is still 30 s and still worth having, for the smaller thing it actually does: it bounds how far
|
|
// a wedged build outlives its budget, and it gives a build that is nearly done the chance to finish
|
|
// its rename rather than be killed inside one. What it does NOT do is prevent that kill, which is
|
|
// why the caller removes the engine's staging files as well as its output (exports.Service.discard).
|
|
const buildStopGrace = 30 * time.Second
|
|
|
|
// BuildOutcome is what running the verb produced, exit code and report together.
|
|
type BuildOutcome struct {
|
|
// Report is the decoded report; Decoded says one arrived on stdout and parsed. Every refusal
|
|
// class speaks on stderr alone, so a refusal legitimately leaves it false.
|
|
Report ingest.BuildReport
|
|
Decoded bool
|
|
// DecodeErr is why stdout did not parse as a report, when it carried bytes that did not.
|
|
DecodeErr error
|
|
// ExitCode is the engine's own exit, valid when Exited. A process that did not exit on its own —
|
|
// killed on the caller's deadline, or never started — leaves Exited false.
|
|
ExitCode int
|
|
Exited bool
|
|
// Stderr is the first line of the engine's stderr — for the operator's log, never for the wire.
|
|
Stderr string
|
|
}
|
|
|
|
// Build writes ONE reader's copy of a book to `out` and reads the report back.
|
|
//
|
|
// It takes no lock and needs none: `build` opens the project READ-ONLY and without the exclusive
|
|
// flock (backend/internal/pipeline/runner.go `openRunner`, store.OpenReadOnly), which is precisely
|
|
// what makes the canon's "a book still being translated may be exported" executable.
|
|
func (r *Runner) Build(ctx context.Context, binary, workdir, format, out string) (BuildOutcome, error) {
|
|
cmd := exec.CommandContext(ctx, binary, BuildArgs(workdir, format, out)...)
|
|
cmd.Dir = workdir
|
|
var stdout, errOut bytes.Buffer
|
|
cmd.Stdout = &limitedBuffer{buf: &stdout, limit: maxBuildReport}
|
|
cmd.Stderr = &errOut
|
|
// SIGTERM, not the default SIGKILL — see buildStopGrace.
|
|
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
|
|
cmd.WaitDelay = buildStopGrace
|
|
err := cmd.Run()
|
|
res := BuildOutcome{Stderr: string(firstLine(errOut.Bytes()))}
|
|
if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
|
|
res.ExitCode, res.Exited = cmd.ProcessState.ExitCode(), true
|
|
}
|
|
if body := bytes.TrimSpace(stdout.Bytes()); len(body) > 0 {
|
|
if rep, derr := ingest.DecodeBuild(body); derr != nil {
|
|
res.DecodeErr = derr
|
|
} else {
|
|
res.Report, res.Decoded = rep, true
|
|
}
|
|
}
|
|
if err != nil && !res.Exited {
|
|
// Killed, or never a process: the exit code carries nothing, so the error is the answer. The
|
|
// caller's own deadline is folded in — an *exec.ExitError from a SIGKILL says less than "the
|
|
// budget ran out".
|
|
return res, errors.Join(fmt.Errorf("runner: tmctl build: %w: %s", err, res.Stderr), ctx.Err())
|
|
}
|
|
return res, nil
|
|
}
|