Skip to content
Ori Pekelman edited this page May 11, 2026 · 1 revision

Tep::Shell

popen-based shell-out + small-file reader / writer. The systems-y bits that pure-Ruby would reach for IO.popen, File.read, File.write to do. None of those lower cleanly through spinel, so this module wraps the C calls behind a tiny API.

When to reach for it

  • Read /proc, /sys, /etc. Cheap and portable from any handler.
  • Shell out to a CLI for one-off captures (e.g. nvidia-smi, uptime, df -h).
  • Atomic file writes (rename-into-place) for state checkpoints.

What it isn't:

  • Not a streaming pipe. Captures are bounded (~64 KiB) and the whole output materialises in memory before returning.
  • Not a process-group manager. There's no kill -9 <pgid>; if a command hangs, the worker hangs.

File reads

hosts = Tep::Shell.read("/etc/hosts")
load1 = Tep::Shell.read("/proc/loadavg").split(" ")[0]

Tep::Shell.read(path) returns the whole file content as a String, or "" on failure. There's an explicit duplication step inside — the underlying FFI returns a pointer to a static buffer that the next call would overwrite, so the result is safely usable after the call returns. (If you're calling into Sock.sphttp_file_read directly, dup with + "" yourself.)

Tep::Shell.read_limited(path, max_bytes) is the same but caps the read at max_bytes — handy when you don't trust the source size.

File writes

Tep::Shell.write("/tmp/state.json", payload)

Returns true on success. Writes are atomic: the content goes to <path>.tmp.<pid> and then renames into place, so a concurrent reader either sees the old content or the new content but never a half-written file.

Capturing a command

out = Tep::Shell.run("nvidia-smi --query-gpu=name --format=csv,noheader")
gpu = out.strip

Tep::Shell.run(cmd) runs the command via popen(3) (so it goes through /bin/sh -c "cmd" — quoting matters), reads stdout to EOF, and returns it. Standard error goes to the worker's stderr.

Tep::Shell.run_limited(cmd, max_bytes) caps the captured bytes — use it when the command might produce a lot of output and you only want the head.

Cookbook

A /healthz endpoint that reads system info

get '/healthz' do
  content_type 'application/json'

  h = Tep.str_hash
  h["uptime_seconds"] = Tep::Shell.read("/proc/uptime").split(" ")[0]
  h["load_1m"]        = Tep::Shell.read("/proc/loadavg").split(" ")[0]
  h["hostname"]       = Tep::Shell.read("/etc/hostname").strip
  Tep::Json.from_str_hash(h)
end

Refresh a cached snapshot

get '/gpu' do
  cached = Tep::Shell.read("/tmp/gpu.cache")
  if cached.length > 0
    return cached
  end
  fresh = Tep::Shell.run("nvidia-smi --query-gpu=utilization.gpu,temperature.gpu --format=csv,noheader")
  Tep::Shell.write("/tmp/gpu.cache", fresh)
  fresh
end

For a real cache, layer a TTL on top via the file's mtime — or move it into a Tep::Job background refresher.

Pitfalls

  • run blocks until the command exits. A hung command (waiting for stdin, dead-locked, runaway) hangs the worker. Use timeout 5 some-cmd at the shell level to bound, or move long shell-outs into a Tep::Job.
  • Quoting is sh-level, not Ruby-level. Tep::Shell.run("echo $USER") expands $USER via the shell. To pass a literal $, single-quote it. To avoid shell quoting entirely, write a small C helper and bind it via FFI; tep itself doesn't ship an execv- shaped runner yet.
  • Static-buffer aliasing. The low-level Sock.sphttp_file_read and Sock.sphttp_shell_capture return pointers into a shared static buffer. The Tep::Shell.read / run wrappers do the necessary + "" dup before returning, but if you go behind the wrapper, two consecutive captures will clobber each other unless you dup.
  • run does not propagate the exit code. If you care, parse $? from the output by including it in the command: Tep::Shell.run("nvidia-smi; echo EXIT:$?") and split on EXIT:.

Clone this wiki locally