Three composable patterns for adding pre-flight logic to tools and pipelines you don't fully control:
1.**Binary Wrapper Interception** — the *delivery mechanism*. Transparently replace a CLI binary with a wrapper that runs custom logic, then `exec`s the real binary. The caller (service, cron, runner) never knows.
2.**GPU/CPU Fallback Routing** — *logic you inject*. Probe a job, route small→GPU / large→CPU, and gate concurrency with `flock` so excess jobs fail-fast and re-queue instead of OOM-killing each other.
3.**Pre-Flight Probe Gate** — *logic you inject*. Cheaply inspect each input and skip the expensive step when the work would be wasted (wrong format, already optimized, corrupt, too large).
They compose: **Pattern 1 is how you deploy; Patterns 2 and 3 are two kinds of pre-flight logic you put inside the wrapper.** The running example throughout is the Whisper auto-captioning / PeerTube transcoder / [[recon]] extraction stack on cortex.
Transparently intercept a CLI binary with a wrapper script that adds pre-flight logic (routing, validation, logging) before exec-ing the real binary. The caller — whether a service, cron job, or another script — never knows the difference.
Use this when you need to modify the behavior of a tool that's called by a system you don't control (e.g., a runner, scheduler, or third-party service), without changing the caller's config or code.
### Prerequisites
- The binary to intercept is installed and working
- You have root/sudo access on the target machine
- The caller invokes the binary by absolute path or via PATH lookup
### Inputs
Prompt the user for all of these before executing:
```
TARGET_HOST= # SSH alias or IP (e.g., cortex). Use "localhost" if local.
BINARY_NAME= # Name of the binary to intercept (e.g., "whisper-ctranslate2")
BINARY_PATH= # Full path to the binary (e.g., "/usr/local/bin/whisper-ctranslate2")
WRAPPER_NAME= # Name for the wrapper script (e.g., "whisper-smart")
WRAPPER_DIR= # Directory for the wrapper (e.g., "/usr/local/bin")
REAL_SUFFIX= # Suffix for the renamed real binary (default: "-real")
```
### Step 1: Locate the Real Binary
Find the actual binary or symlink that will be intercepted.
**Gate:** Both must succeed. If the binary fails or produces different output than before, the wrapper has a bug — check argument passing (quoting, `$@` vs `$*`).
### Step 6: Verify Service Integration
If the binary is called by a service (systemd, cron, etc.), restart that service and confirm it picks up the wrapper.
No service restart needed — next invocation hits the real binary directly.
### Key Principles
1.**`exec` is mandatory.** Without `exec`, the wrapper runs the binary as a child process, which breaks signal handling (SIGTERM won't reach the real binary) and doubles PID usage. `exec` replaces the wrapper process entirely.
2.**Use `"$@"` not `$@` or `$*`.** Quoted `"$@"` preserves argument boundaries. Unquoted `$@` splits arguments with spaces. `$*` merges all arguments into one string.
3.**Appended flags override earlier ones.** Many CLI tools (argparse, getopt) use last-value-wins for duplicate flags. The wrapper can append `--flag value` after `"$@"` to force overrides without removing the caller's original flags.
4.**Exit codes matter.** If pre-flight fails, exit with a non-zero code the caller understands. Some callers retry on specific exit codes (e.g., PeerTube runner retries on exit 1).
5.**Log to /tmp, not the service's log directory.** The wrapper log is a debug artifact, not part of the service's data. `/tmp` is cleaned on reboot, which is fine for wrapper logs.
### Troubleshooting
- **Wrapper not being called:** Check the symlink chain (`ls -la $BINARY_PATH`). If the service uses a hardcoded absolute path that bypasses PATH, the symlink might be in the wrong location.
- **Arguments with spaces break:** Use `"$@"` (quoted) in the exec line, not `$@` (unquoted).
- **Service fails after wrapper install:** Check the shebang (`#!/bin/bash`), permissions (`chmod +x`), and that `exec` is present.
- **Wrapper log is empty:** The service might be calling a different path. Check `which $BINARY_NAME` vs what the service config specifies.
### Example — Whisper transcription routing (PeerTube runner on cortex)
The [[peertube-remote-runner]] calls `whisper-ctranslate2` for auto-captioning. The smart wrapper intercepts this to route short videos to GPU and long videos to CPU (the routing logic itself is **Pattern 2** below).
- ffprobe audio duration from first non-flag argument
- < 1hr → exec with --device cuda --compute_type float16
- >= 1hr → exec with --device cpu --compute_type int8
- Appends --model medium after $@ (last-value-wins override)
```
---
# Pattern 2 — GPU/CPU Fallback Routing
Route workloads to GPU or CPU based on pre-flight inspection of job properties (duration, file size, resolution, complexity). Small jobs go to GPU for speed; large jobs fall back to CPU to avoid VRAM exhaustion. Concurrent job control via `flock` prevents OOM kills — excess jobs fail fast and re-queue instead of competing for memory.
Use this when you have a GPU workload where some jobs exceed VRAM capacity, and the system needs to handle both small and large jobs without manual intervention or OOM kills. **Deploy it via Pattern 1** (the router script *is* the wrapper's pre-flight logic).
### Prerequisites
- NVIDIA GPU with working drivers (`nvidia-smi` returns output)
- Both GPU and CPU execution paths available for the workload
- A probe tool to inspect job properties before execution (e.g., `ffprobe`, `mediainfo`, `file`, `wc`)
- A caller that retries on non-zero exit codes (scheduler, job queue, runner)
### Inputs
```
TARGET_HOST= # Machine with GPU (e.g., cortex)
WORKLOAD_BINARY= # The tool that processes jobs (e.g., "whisper-ctranslate2-real")
PROBE_TOOL= # Tool to inspect job properties (e.g., "ffprobe", "mediainfo")
GPU_VRAM_MB= # Total VRAM available (e.g., 16384 for 16GB)
WORKLOAD_VRAM_MB= # VRAM used per GPU job (e.g., 3700)
WORKLOAD_RAM_MB= # RAM used per CPU job (e.g., 11000)
THRESHOLD_VALUE= # Cutoff for GPU vs CPU routing (e.g., 3600 for seconds)
THRESHOLD_UNIT= # What the threshold measures (e.g., "seconds", "bytes", "pixels")
MAX_GPU_JOBS= # Max concurrent GPU jobs (e.g., 2)
MAX_CPU_JOBS= # Max concurrent CPU jobs (e.g., 1)
GPU_ARGS= # Arguments for GPU execution (e.g., "--device cuda --compute_type float16")
CPU_ARGS= # Arguments for CPU execution (e.g., "--device cpu --compute_type int8")
```
### Step 1: Determine the Routing Threshold
Profile representative workloads to find the VRAM crossover point.
**Gate:** You must have a clear, measurable property that predicts VRAM usage. If the relationship is unpredictable, this pattern won't work — use a different strategy (e.g., try GPU first, fall back on OOM).
- **`flock --nonblock`**: Non-blocking lock attempt. If the slot is taken, exit immediately instead of waiting — this prevents queue starvation where all runner slots block waiting for CPU jobs.
- **Exit code 1**: The caller (runner, scheduler) should interpret this as "retry later." Most job queues do by default.
- **`exec`**: Replace the router process with the workload binary so signals, exit codes, and resource limits pass through cleanly.
- **Lock files in `/tmp`**: Auto-cleaned on reboot. No stale locks after crashes.
### Step 4: Integrate with the Caller
Deploy the router using **Pattern 1 (Binary Wrapper Interception)**:
1. Rename the real binary: `mv $BINARY → ${BINARY}-real`
2. Write the router script (above) as the wrapper
3. Symlink: `ln -sf /path/to/router $BINARY`
Or, if the caller supports configurable command paths, point it directly at the router.
### Step 5: Verify Both Paths
```bash
# GPU path — submit a small job, expect mode=GPU + GPU utilization
If BLOCKED count is high relative to CPU count, the threshold may be too aggressive (routing too many jobs to CPU). Raise the threshold or increase `MAX_CPU_JOBS` if RAM allows.
### Memory Budget Worksheet
```
GPU path:
VRAM per job: $WORKLOAD_VRAM_MB MB
Max GPU jobs: $MAX_GPU_JOBS
Total GPU VRAM: $GPU_VRAM_MB MB
Headroom: GPU_VRAM_MB - (WORKLOAD_VRAM_MB × MAX_GPU_JOBS) MB → must be positive
CPU path:
RAM per job: $WORKLOAD_RAM_MB MB
Max CPU jobs: $MAX_CPU_JOBS
System RAM: $(free -m | awk '/Mem:/{print $2}') MB
Other processes: ~2-4 GB (OS, services, buffers)
Headroom: SystemRAM - OtherProcs - (WORKLOAD_RAM_MB × MAX_CPU_JOBS) MB → must be positive
systemd MemoryMax: Set to MAX(GPU peak, CPU peak) + 20% buffer
```
### Troubleshooting
- **GPU job OOM-kills despite being under threshold:** Threshold too high, or VRAM varies by input beyond what the probe measures. Lower it or add a secondary probe (e.g., resolution in addition to duration).
- **CPU jobs pile up and exhaust RAM:** `MAX_CPU_JOBS` too high, or `flock` isn't working. Check lock files in `/tmp/` and the `exec 9>` redirect.
- **All jobs route to CPU:** The probe returns 0 or fails silently. Test it manually: `$PROBE_TOOL -v quiet -show_entries format=duration -of csv=p=0 /path/to/input`. Empty result usually means a permissions/path issue.
- **Blocked jobs never get retried:** The caller doesn't retry on exit 1. Match the exit code to what the caller expects (some want 75 for "temporary failure").
- **Lock files persist after crash:** `/tmp` clears on reboot, so locks self-heal. For immediate cleanup: `rm /tmp/cpu-workload.lock`.
### Example — Whisper auto-captioning on PeerTube runner (cortex)
WORKLOAD_RAM_MB=11000 # Whisper medium on CPU int8 (peak for 9.5hr video)
THRESHOLD_VALUE=3600 # 1 hour in seconds
THRESHOLD_UNIT=seconds
MAX_GPU_JOBS=2 # Runner concurrency=2, both can be GPU
MAX_CPU_JOBS=1 # Only 1 CPU job at a time (11GB peak, 20G MemoryMax)
GPU_ARGS="--device cuda --compute_type float16"
CPU_ARGS="--device cpu --compute_type int8"
Result: 4100+ videos captioned. ~20 videos over 1 hour routed to CPU.
GPU jobs: ~3.7GB VRAM, 88-99% GPU utilization. CPU jobs: ~8-11GB RAM, serialized via flock.
MemoryMax=20G on the runner service as safety net.
```
---
# Pattern 3 — Pre-Flight Probe Gate
Insert a cheap inspection step before expensive processing in a pipeline. Probe the input (ffprobe, mediainfo, file headers, checksums) to skip work that will be wasted — wrong format, already optimized, below quality threshold, or too large to process safely. Log every decision for an audit trail. Keep a post-processing safety net as backup.
Use this when your pipeline processes files in bulk and a significant percentage of inputs don't need the expensive step, or when processing the wrong input would waste time, storage, or GPU cycles. Like Pattern 2, the gate can live inside a Pattern 1 wrapper, or inline in a processing loop.
### Prerequisites
- A pipeline with at least one expensive processing step (transcoding, inference, embedding, etc.)
- A probe tool that can inspect inputs cheaply (< 1 second per file)
- Clear criteria for what constitutes a "skip" vs "process" decision
### Inputs
```
PIPELINE_NAME= # Human-readable name (e.g., "video-transcoder", "pdf-extractor")
- **Return codes**: 0 = process (matches shell "success" convention), 1 = skip, 2 = fail. Callers branch on `$?`.
- **Structured log lines**: Every decision logged with timestamp, verdict, filename, reason — parseable by grep/awk.
- **Probe errors = FAIL, not SKIP**: If the probe itself fails, the file might be corrupt — route to fail dir for inspection rather than silently skipping.
The probe gate is the primary filter, but add a post-processing check as backup. This catches cases where the probe was wrong (e.g., file reported as H.264 but was actually corrupt).
```bash
post_process_check() {
local OUTPUT="$1"
local TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
# Size gate: output should be at least 10% of input size
local INPUT_SIZE=$2
local OUTPUT_SIZE=$(stat -c%s "$OUTPUT" 2>/dev/null)
- **Probe is slow (> 1s/file):** Some tools read more than necessary. For ffprobe, use `-analyzeduration 1000000 -probesize 1000000`. For large PDFs, `pdfinfo` beats opening the file in Python.
- **Probe reports wrong codec/format:** Container and stream codecs can mismatch. Probe the stream level: `ffprobe -v quiet -select_streams v:0 -show_entries stream=codec_name -of csv=p=0 "$INPUT"`.
- **Skipped files that should have been processed:** Review the skip log; lower the threshold or add exceptions. The skip directory preserves files for re-processing if criteria change.
- **Post-processing catches failures the probe missed:** The safety net working as intended. Investigate why the probe missed it; add a new criterion if the pattern is common.
### Example A — PeerTube H.265 transcoding pipeline (cortex)
```
PIPELINE_NAME=video-transcoder
PROBE_TOOL=ffprobe
INPUT_DIR=/opt/bulk-import/completed
OUTPUT_DIR=/opt/bulk-import/transcoded
SKIP_DIR=/opt/bulk-import/skipped
FAIL_DIR=/opt/bulk-import/failed
Probe criteria:
- codec_name == "hevc" → SKIP (already H.265)
- height < 240 → SKIP (too low quality to bother)
- duration == 0 → FAIL (corrupt or audio-only)
- probe exit != 0 → FAIL (unreadable)
Post-processing safety net:
- output size < 10% of input → FAIL (transcode produced garbage)
- ffprobe on output fails → FAIL (corrupt output)
Result: Saved ~15% of GPU cycles by skipping already-optimized files.
```
### Example B — PDF extraction pipeline (RECON on VM 1130)
```
PIPELINE_NAME=pdf-extractor
PROBE_TOOL=pdfinfo
INPUT_DIR=/mnt/library/incoming
OUTPUT_DIR=/opt/recon/extracted
Probe criteria:
- Pages > 500 → route to Gemini Vision (OCR too slow)
- Pages == 0 → FAIL (corrupt PDF)
- File size < 1KB → SKIP (empty/placeholder)
- Encrypted: yes → SKIP (can't extract without password)
- Already in SQLite status table → SKIP (dedup)
```
---
*Merged 2026-06-15 from `binary-wrapper-interception.md`, `gpu-cpu-fallback-routing.md`, and `pipeline-probe-gate.md` (originally written 2026-02-17).*