Help us shape the future of Go! Take the 2026 Go Developer Survey

subprocess

package
v1.9.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 26, 2026 License: MIT Imports: 19 Imported by: 0

Documentation

Overview

Package subprocess runs job handlers in a re-exec'd child process.

The worker launches its own binary again with argv[1] set to shim.ArgName, so the child ends up running shim.Main with the same handler registry the worker itself has, by construction — there is no second binary to build, ship, or keep in sync. The request crosses to the child on fd 3 and the result crosses back on fd 4, using the wire package's length-prefixed frames; stdout and stderr are left free for the handler's own output and for whatever a native library writes, and are streamed to the configured logger instead.

This is Dispatch's exec.LevelProcess rung: a crash, a panic, or a memory-unsafe parser going off the rails takes down the child, not the worker, and the child does not receive the worker's environment wholesale — buildEnv constructs it from the request's own Env plus a small fixed allowlist (PATH, HOME, TMPDIR) copied from the worker's, never a plain pass-through of os.Environ() — so it cannot read a credential that lived only in a worker environment variable outside that allowlist. It is not a sandbox in the mount/network/seccomp sense — that is exec.LevelSandboxed, a stronger rung built on the same wire protocol.

The uid/gid boundary

WithUser configures a dedicated, low-privilege uid/gid for the child. It is required, not advisory: without one, the child would run as the worker's own uid and could read anything the worker can — the Dispatch config file on disk among it, which is where database credentials typically live, not just the worker's environment — so Run refuses to start at all unless WithAllowSameUser opts into that explicitly. See WithUser and WithAllowSameUser.

A genuine uid drop — WithUser naming anything other than the worker's own uid — clears supplementary groups along with it: dropping to a different uid or gid already requires the same privilege setgroups(2) itself needs (CAP_SETUID/CAP_SETGID on Linux, root on Darwin), so os/exec's own "clear supplementary groups whenever Credential is set and NoSetGroups is false" behaviour applies in full (see sysProcAttr, procattr_unix.go). A worker running as root with, say, "docker" in its supplementary groups (a common shape for a systemd unit that also manages containers) does not hand that membership to a genuinely dropped child — the child keeps only the low-privilege uid/gid's own groups.

The one case where supplementary groups are NOT cleared is WithAllowSameUser's same-uid, same-gid path, where Credential is otherwise a no-op: NoSetGroups is set there specifically because setgroups needs privilege this process does not have when it is not already root (every dev machine and CI run), and there is nothing to clear anyway, since the child is running as the worker's own account. That path already defeats most of this rung's purpose for the uid/gid boundary itself — see WithUser and WithAllowSameUser — so its supplementary-group behaviour is the smaller of the two problems.

Index

Constants

View Source
const Name = "subprocess"

Name is the identifier this executor registers under.

Variables

This section is empty.

Functions

func Available

func Available() error

Available reports whether this platform can run the subprocess rung at all — always true on Unix. It only answers the platform question; checkLaunch (and SameUserRefused, above) answers different ones — does THIS configuration name a uid at all, and if so, is it the worker's own — which only checkLaunch itself catches on the actual launch path, once Run is called for a job attempt. Configuration code should call Available before ever constructing an Executor, so a deployment on an unsupported platform fails once, loudly, at startup — not job by job, on every attempt's launch failure, once it is already running. Check hasUser and call SameUserRefused alongside it for the same reason.

func SameUserRefused

func SameUserRefused(uid int, allowSameUser bool) bool

SameUserRefused reports whether checkLaunch would refuse to launch a child configured with this uid and allowSameUser setting because the uid matches the worker's own — one of the two conditions checkLaunch checks, factored out so configuration code can ask the same question before ever constructing an Executor, without keeping a second, independent copy of checkLaunch's own logic that could drift from it silently. It does not check o.hasUser, and so does not answer checkLaunch's other condition — no uid configured at all — which callers must check separately; see resolveExecutionOptions (extension/execution.go) for the shape that checking both looks like.

Types

type Executor

type Executor struct {
	// contains filtered or unexported fields
}

Executor runs handlers in a child process, re-exec'ing the configured binary as the shim for every attempt. It satisfies exec.Executor at exec.LevelProcess: a crash or a memory-unsafe parser going off the rails takes the child down, not the worker, and the child receives a constructed environment rather than the worker's own.

func New

func New(opts ...Option) *Executor

New creates a subprocess executor from options. Absent WithLogger, child output is discarded rather than reaching the worker's own stdout/stderr. Absent WithScratchDir, os.TempDir() is used.

func (*Executor) Close

func (e *Executor) Close() error

Close releases the executor's own resources. Subprocess holds none — every pipe and process it creates is scoped to a single Run.

func (*Executor) Level

func (e *Executor) Level() exec.Level

Level reports that this executor isolates the handler into its own address space, but does not sandbox it — no mount, network, or PID namespace, no seccomp filter.

func (*Executor) Name

func (e *Executor) Name() string

Name identifies the executor.

func (*Executor) Reclaim

func (e *Executor) Reclaim(context.Context, id.WorkerID) error

Reclaim is a no-op for this rung. That is no longer because nothing can leak: the child used to share the worker's own process group, which would let a signal aimed at the worker's whole group reach it too, but now that it is its own group leader (see sysProcAttr in procattr_unix.go, Setpgid), a worker that dies mid-attempt can leave an orphaned child running with nothing left to signal it. Actually sweeping those up is deferred — it needs a worker identity stable across restarts, which this rung does not have today; see the SDD ledger's Phase 3 note on the same gap for the stronger rungs.

func (*Executor) Run

func (e *Executor) Run(ctx context.Context, req *exec.Request) (*exec.Result, error)

Run launches the child, feeds it req over fd 3, and waits for either a result frame on fd 4 or the process ending on its own. A returned error means the child never started; every other outcome, including the child crashing or missing its deadline, is reported through Result.Status.

func (*Executor) Settings

func (e *Executor) Settings() Settings

Settings returns safe inspection fields. The subprocess requests a zero core-dump limit.

type Option

type Option func(*options)

Option configures an Executor.

func WithAllowSameUser

func WithAllowSameUser() Option

WithAllowSameUser is the single opt-out for running this rung unisolated on the uid boundary. Without it, Run refuses to start in either of the two shapes that leave the child running as the worker's own uid: WithUser never called at all, or WithUser naming the worker's own uid explicitly. Both leave the child able to read every credential the isolation exists to hide — ~/.aws, /var/run/secrets, the Dispatch config itself — so both share this one switch rather than each getting its own.

func WithArgs

func WithArgs(args ...string) Option

WithArgs sets extra arguments appended after shim.ArgName when launching the child. Most deployments need none of these — the marker argument is enough for the binary to know to run the shim.

func WithBinary

func WithBinary(path string) Option

WithBinary sets the path to the binary the executor re-execs for every attempt. In production this is the worker's own executable, found via os.Executable; tests pass os.Args[0] so the re-exec'd child is the test binary itself, running the shim instead of go test.

func WithEnv

func WithEnv(env map[string]string) Option

WithEnv supplies the base environment for the child. It is merged with Request.Env, which wins on any key both sides set, and with a small fixed allowlist (PATH, HOME, TMPDIR) copied from the worker's own environment. The child never inherits os.Environ() wholesale — that is the entire point of this rung, since the worker's environment is where its own credentials tend to live.

func WithLogger

func WithLogger(l log.Logger) Option

WithLogger sets where the child's stdout and stderr are streamed, each line tagged with the job's id and name. The default is a no-op logger, so output is silently discarded rather than reaching os.Stdout, which would interleave a handler's own output with the worker's.

func WithRlimits

func WithRlimits(r Rlimits) Option

WithRlimits configures POSIX resource limits for the child. buildEnv passes non-zero fields to the child as environment variables, and shim.Main applies them via syscall.Setrlimit before running the handler — see the Rlimits doc comment for why that happens child-side.

func WithScratchDir

func WithScratchDir(path string) Option

WithScratchDir sets the directory under which each attempt gets a fresh working directory for the child's process (Cmd.Dir). It defaults to os.TempDir(). This is distinct from Request.OutputDir: that is where the handler writes artifacts through the accessor, while this is just a safe place for the process to start in, so a handler that writes a relative path outside the artifact API lands somewhere disposable instead of the worker's own working directory.

func WithStrictRlimits

func WithStrictRlimits() Option

WithStrictRlimits makes a configured rlimit that did not actually take effect a launch failure instead of a warning — with one exception. shim.Main (applyRlimits) still treats the current kernel's own structural refusal to support a given limit at all as a warning regardless of this option: Darwin rejecting setrlimit(RLIMIT_AS, ...) unconditionally is the standing example, and there is nothing a caller-supplied value could have done differently about that, so making it fatal here would only make WithRlimits{AddressSpace: ...} unusable on Darwin rather than catch a real misconfiguration.

Everything else this rung can fail on, it does catch, including two shapes worth naming explicitly: a value that exceeds the process's own hard limit (EPERM on a platform that does support the resource), and — distinct from the kernel refusing the limit — Dispatch itself not having verified the raw resource number for the current platform at all (RLIMIT_NPROC on most non-Linux/Darwin/FreeBSD Unixes, currently; see rlimitNProc in exec/shim/rlimit_unix.go). That second case is a library gap, not a platform fact, and without this option it would otherwise be indistinguishable, from the outside, from the limit simply having applied.

Without this, a configured rlimit that fails to apply is logged to the child's stderr and otherwise ignored — see the Rlimits doc comment — which is invisible by default unless WithLogger is also configured, since WithLogger's own default discards output silently. An operator who wants a guarantee that a configured limit actually took effect, not just an attempt at one, should use this rather than relying on stderr being watched.

func WithUser

func WithUser(uid, gid int) Option

WithUser configures the uid and gid the child runs as, dropped via Credential on sysProcAttr before exec. Run refuses to start when no uid is configured at all, and also when one is configured that matches the worker's own, unless WithAllowSameUser is also given — see its doc comment for why.

A uid or gid that genuinely differs from the worker's own also clears the child's supplementary groups, along with the primary uid/gid — see the package doc comment's "The uid/gid boundary" section for why that falls out of the same privilege check rather than needing separate handling, and for the one path (WithAllowSameUser) where it does not happen.

type Rlimits

type Rlimits struct {
	// AddressSpace caps RLIMIT_AS in bytes. Note Darwin's kernel rejects
	// setrlimit(RLIMIT_AS, ...) outright (EINVAL) regardless of value —
	// shim.Main logs and continues rather than failing the attempt when
	// that happens, since the rest of the isolation (uid boundary,
	// process-group kill, the other limits) still holds.
	AddressSpace int64
	// NoFile caps RLIMIT_NOFILE, the open file descriptor count.
	NoFile int64
	// NProc caps RLIMIT_NPROC, the number of processes the child's uid
	// may run — a second line of defence against a forking exploit even
	// with the process group killed on timeout.
	NProc int64
	// Core caps RLIMIT_CORE. buildEnv forces the child's actual limit to
	// zero unconditionally, regardless of what is set here, so a
	// segfaulting parser cannot dump the input that crashed it, and the
	// worker's memory alongside it, to disk. This field is accepted for
	// API symmetry with the other limits but has no effect.
	Core int64
	// FSize caps RLIMIT_FSIZE in bytes, bounding how much a runaway
	// handler can write before the kernel kills it outright.
	FSize int64
}

Rlimits configures the POSIX resource limits applied to the child process. Go cannot set a child's rlimits through SysProcAttr, so buildEnv passes these child-side as environment variables (see shim.EnvRlimitAS and friends), and shim.Main applies them via syscall.Setrlimit before running the handler. Zero means "leave the limit at whatever the worker itself runs with."

type Settings

type Settings struct {
	UserConfigured                           bool
	UID, GID                                 int
	AllowSameUser, HasRlimits, StrictRlimits bool
	Rlimits                                  Rlimits
	ScratchDir                               string
}

Settings describes process isolation without exposing its environment or arguments. Rlimits are requested ceilings. Kernel support still determines enforcement.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL