Day 17 — os/exec, Time & Network Basics
Day 17 — os/exec, Time & Network Basics
Day 42 — os & os/exec
Stage V · ~3h
Goal: Cross the process boundary safely—read env and files, spawn subprocesses with timeouts, capture stdout/stderr, and never shell-inject yourself.
Why this day exists
Services and CLIs constantly touch the OS:
- Config paths, PID files, temporary dirs
- Feature flags via environment
- Wrapping
git,ffmpeg, cloud CLIs, or health probes
os/exec is powerful and easy to misuse (hangs, leaked processes, injection via bash -c).
Theory 1 — os essentials
Files
data, err := os.ReadFile("config.json") // whole file
err = os.WriteFile("out.txt", data, 0o644) // create/truncate
f, err := os.Open("in.txt") // read-only
defer f.Close()
f, err = os.OpenFile("a.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)Paths & dirs
dir := os.TempDir()
err := os.MkdirAll("state/cache", 0o755)
info, err := os.Stat(path)
if errors.Is(err, os.ErrNotExist) { /* ... */ }
if info.IsDir() { /* ... */ }Environment
val, ok := os.LookupEnv("API_TOKEN")
port := os.Getenv("PORT") // "" if unset — often ambiguous
os.Setenv("DEBUG", "1") // process-localPrefer explicit LookupEnv when empty string is valid.
Args & exit
os.Args[0] // program name
os.Exit(1) // skips deferred calls in this goroutine's stack — prefer returning errors from mainPattern:
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}Theory 2 — os/exec model
cmd := exec.Command("echo", "hello", "world") // argv list — no shell
out, err := cmd.Output() // stdout; err may be *exec.ExitError| API | Captures | Notes |
|---|---|---|
Run |
nothing | Start+wait |
Output |
stdout | stderr discarded unless set |
CombinedOutput |
stdout+stderr merged | Handy for logs |
Start/Wait |
manual | Streaming pipes |
Always prefer argv over shell
// BAD — injection if user controls s
exec.Command("sh", "-c", "echo "+s)
// GOOD
exec.Command("echo", s)If you truly need a shell, document why and sanitize ruthlessly.
Theory 3 — Context deadlines
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sleep", "30")
err := cmd.Run()
// ctx exceeded → process killed (implementation: kill process group nuances on Unix)CommandContext sends kill when ctx is done. Always set timeouts on untrusted or external tools.
Capture streams with pipes
cmd := exec.CommandContext(ctx, "git", "status", "--porcelain")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
return fmt.Errorf("git status: %w\nstderr=%s", err, stderr.String())
}Exit codes
var ee *exec.ExitError
if errors.As(err, &ee) {
code := ee.ExitCode()
_ = code
}Theory 4 — Stdin and interactive-ish tools
cmd := exec.Command("grep", "foo")
cmd.Stdin = strings.NewReader("foo\nbar\n")
out, err := cmd.Output()For large data, use cmd.StdinPipe() and write in a goroutine; close the pipe when done so the child sees EOF.
Theory 5 — Security & operations checklist
- Fixed binary path when possible (
/usr/bin/git) or resolve once.
- No user-controlled executable name without allowlist.
- Timeouts always for network-bound tools.
- Env scrubbing:
cmd.Env = append(os.Environ(), "KEY=val")or minimal env.
- Working directory:
cmd.Dir = dir.
- Signals: understand that killing parent may leave children unless process groups are set (
SysProcAttron Unix—advanced).
Worked example — run with timeout helper
package runx
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"time"
)
type Result struct {
Stdout string
Stderr string
ExitCode int
}
func Command(ctx context.Context, timeout time.Duration, name string, args ...string) (Result, error) {
if timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}
cmd := exec.CommandContext(ctx, name, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
res := Result{Stdout: stdout.String(), Stderr: stderr.String()}
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
res.ExitCode = ee.ExitCode()
}
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return res, fmt.Errorf("timeout after %s running %s: %w", timeout, name, err)
}
return res, fmt.Errorf("%s: %w (stderr=%q)", name, err, res.Stderr)
}
return res, nil
}Lab 1 — Setup
mkdir -p ~/lab/90daysofx/01-go/day42
cd ~/lab/90daysofx/01-go/day42
go mod init example.com/day42Implement runx and a small CLI cmd/runxdemo.
Lab 2 — Happy path tests
Use portable commands:
func TestEcho(t *testing.T) {
res, err := Command(context.Background(), 2*time.Second, "echo", "hi")
if err != nil {
t.Fatal(err)
}
if got := strings.TrimSpace(res.Stdout); got != "hi" {
t.Fatalf("got %q", got)
}
}On Windows, adjust binary names; this volume assumes a Unix-like lab shell.
Lab 3 — Timeout behavior
func TestTimeout(t *testing.T) {
_, err := Command(context.Background(), 200*time.Millisecond, "sleep", "5")
if err == nil {
t.Fatal("expected timeout error")
}
}Confirm the error chain mentions timeout. Optionally check no long-lived sleep remains (pgrep manually).
Lab 4 — File + env integration
Write a tool that:
- Reads
APP_ENV(defaultdev)
- Loads
./config.$APP_ENV.jsonviaos.ReadFile
- Runs
echowith a summary line of config size and env
Reject path traversal if you accept a user -config flag (filepath.Clean, stay under workdir).
Lab 5 — Combined stderr (stretch)
Run a command that writes to stderr (ls of missing path). Ensure your helper surfaces stderr in the returned error string. Assert with errors.As on exit code ≠ 0.
Common gotchas
| Gotcha | Fix |
|---|---|
Command(userInput) as shell |
Argv list only; allowlist |
| No timeout | CommandContext + deadline |
| Ignoring stderr on failure | Capture and wrap |
os.Exit in libraries |
Return error |
| Relative binary relying on PATH | Document PATH; consider absolute |
| Deadlock with full pipe buffers | Copy stdout/stderr concurrently if large |
Assuming Output includes stderr |
It doesn’t—set cmd.Stderr |
Checkpoint
ReadFile/WriteFile/MkdirAllused intentionally
- Subprocess helper with timeout + stdout/stderr capture
- Test proves timeout kills long sleep
- Can explain why
sh -cis dangerous
- Exit codes inspected via
ExitError
mainreturns errors without losing defers carelessly
Commit
git add .
git commit -m "day42: os/exec with timeouts and safe argv"Write three personal gotchas before continuing.
Tomorrow
Day 43 — time: durations, timers, tickers, locations, monotonic clocks, and a cancellable mini-scheduler.
Day 43 — time, timers & tickers
Stage V · ~3h
Goal: Use time correctly for scheduling and deadlines—locations, monotonic measurements, timers/tickers that you actually stop—and build a context-aware mini-scheduler.
Why this day exists
Distributed systems fail on time:
- “Works in UTC, breaks in local TZ”
- Comparing wall clocks across machines
- Leaking
time.Tickergoroutines
- Using
time.Sleepin hot loops without cancellation
time is small API surface, large operational impact.
Theory 1 — Instant, duration, location
now := time.Now() // wall + monotonic reading
d := 500 * time.Millisecond
later := now.Add(d)
elapsed := time.Since(now) // uses monotonic when possible
delta := later.Sub(now)Duration
const day = 24 * time.Hour
d, err := time.ParseDuration("1h30m") // "300ms", "2h45m"Format for humans: d.String() → "1h30m0s".
Wall vs monotonic
time.Now() stores wall clock and monotonic clock. Subtractions between Time values from Now ignore wall adjustments (NTP step) for elapsed measurement. Serializing to JSON loses monotonic reading—only wall remains after Marshal/Unmarshal.
Theory 2 — Parsing and formatting
Go’s reference time is not YYYY-MM-DD. It is:
Mon Jan 2 15:04:05 MST 2006
t, err := time.Parse(time.RFC3339, "2026-07-27T15:04:05Z")
s := t.UTC().Format(time.RFC3339Nano)
// custom:
s = t.Format("2006-01-02 15:04:05")Prefer predefined layouts (RFC3339, DateOnly since 1.20, etc.) when possible.
Locations
loc, err := time.LoadLocation("America/New_York")
local := t.In(loc)time.Local depends on machine config. Servers: store UTC, display with explicit location.
time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)Theory 3 — Timer and Ticker
Timer (once)
timer := time.NewTimer(2 * time.Second)
defer timer.Stop()
select {
case <-timer.C:
// fired
case <-ctx.Done():
if !timer.Stop() {
<-timer.C // drain if stop lost the race
}
return ctx.Err()
}Or time.After (convenient, but cannot be garbage-collected until it fires—avoid in tight loops).
Ticker (periodic)
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case t := <-ticker.C:
_ = t // tick time
case <-ctx.Done():
return ctx.Err()
}
}Always Stop tickers you create when the loop ends.
time.Tick
Discouraged for long-lived processes—no way to stop. Prefer NewTicker.
Theory 4 — Sleep and cancellation
// cancellable sleep
func Sleep(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}Never block shutdown paths on uncancellable time.Sleep.
Theory 5 — Deadlines for I/O (preview)
deadline := time.Now().Add(5 * time.Second)
_ = conn.SetDeadline(deadline)Day 44 uses this on sockets; the idea starts here: absolute deadlines often beat stacked relative sleeps.
Worked example — mini scheduler
package sched
import (
"context"
"log/slog"
"time"
)
// Every runs fn immediately (optional) then every interval until ctx cancel.
func Every(ctx context.Context, interval time.Duration, runImmediately bool, fn func(context.Context) error) error {
if interval <= 0 {
return errNonPositive
}
if runImmediately {
if err := fn(ctx); err != nil {
return err
}
}
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
if err := fn(ctx); err != nil {
slog.Error("tick failed", "err", err)
// choose: continue or return — document policy
}
}
}
}Define errNonPositive with errors.New. Production schedulers add jitter, overlap guards (sync.Mutex or skip if previous still running), and metrics—keep today focused.
Overlap-safe variant sketch
var gate sync.Mutex
// on tick:
if !gate.TryLock() {
return // skip overlapping run
}
defer gate.Unlock()
_ = fn(ctx)(TryLock is available in modern Go sync.Mutex.)
Lab 1 — Setup
mkdir -p ~/lab/90daysofx/01-go/day43
cd ~/lab/90daysofx/01-go/day43
go mod init example.com/day43Lab 2 — Duration & location drills
Write tests:
ParseDuration("1500ms")equals1500 * time.Millisecond
- Round-trip RFC3339 UTC
- Convert a fixed instant between
UTCandAsia/Kolkata(or your TZ)—assert hour offset
func TestRFC3339RoundTrip(t *testing.T) {
in := "2026-01-02T03:04:05Z"
ts, err := time.Parse(time.RFC3339, in)
if err != nil {
t.Fatal(err)
}
if ts.UTC().Format(time.RFC3339) != in {
t.Fatal(ts)
}
}Lab 3 — Scheduler with cancel
Implement Every and test with a fake clock or short real intervals:
func TestEveryCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
var n atomic.Int32
done := make(chan error, 1)
go func() {
done <- Every(ctx, 20*time.Millisecond, true, func(context.Context) error {
n.Add(1)
return nil
})
}()
time.Sleep(70 * time.Millisecond)
cancel()
err := <-done
if !errors.Is(err, context.Canceled) {
t.Fatalf("err=%v", err)
}
if n.Load() < 2 {
t.Fatalf("ticks=%d", n.Load())
}
}Lab 4 — Cancellable sleep helper
Unit test that Sleep(ctx, time.Hour) returns quickly when cancel() is called.
Lab 5 — Wall clock journaling (stretch)
Log time.Now().Format(time.RFC3339Nano) every second for 5s. Change system TZ mid-lab if you can; note display changes vs UTC storage practice.
Common gotchas
| Gotcha | Fix |
|---|---|
time.Tick leak |
NewTicker + Stop |
time.After in loop |
NewTimer + reset/stop carefully |
| Local TZ in APIs | UTC on wire |
Wrong layout (YYYY) |
Use 2006 reference |
| Ignoring ticker.Stop | Always defer Stop |
| Sleep without ctx | Cancellable helper |
| Comparing times from different zones carelessly | Normalize with .UTC() or .Equal |
Checkpoint
- Explain monotonic vs wall in one sentence
- Format/parse RFC3339 correctly
- Scheduler stops on cancel and stops ticker
- Overlap policy documented (skip vs queue)
- No
time.Tickin long-lived code
- Cancellable sleep tested
Commit
git add .
git commit -m "day43: time scheduler with tickers and cancel"Write three personal gotchas before continuing.
Tomorrow
Day 44 — net basics: TCP listen/dial, net.Conn deadlines, and an echo server + client you can test on localhost.
Day 44 — net basics (TCP)
Stage V · ~3h
Goal: Understand sockets under HTTP—listen, dial, read/write with deadlines, and ship a TCP echo server + client with tests.
Why this day exists
net/http will dominate Days 45–50, but HTTP rides on net.Conn. When debugging resets, half-open connections, or custom protocols, you need the TCP layer:
Listen/Accept
Dial/DialContext
- Deadlines and buffer reads
- Graceful close vs abort
Theory 1 — Listen and Accept
ln, err := net.Listen("tcp", "127.0.0.1:0") // :0 → ephemeral port
if err != nil {
return err
}
defer ln.Close()
addr := ln.Addr().String()
for {
conn, err := ln.Accept()
if err != nil {
return err // or continue on temporary errors
}
go serve(conn)
}Address forms
| Address | Meaning |
|---|---|
"127.0.0.1:8080" |
IPv4 loopback |
"[::1]:8080" |
IPv6 loopback |
":8080" |
All interfaces, port 8080 |
"localhost:8080" |
Resolver-dependent |
Prefer loopback in tests.
Theory 2 — Dial
d := net.Dialer{Timeout: 3 * time.Second}
conn, err := d.DialContext(ctx, "tcp", addr)
// or: net.DialTimeout("tcp", addr, 3*time.Second)
defer conn.Close()Always bound dial with timeout/context—hanging dials freeze clients.
Theory 3 — Conn I/O and deadlines
net.Conn implements io.ReadWriteCloser plus:
conn.SetDeadline(time.Now().Add(5 * time.Second))
conn.SetReadDeadline(...)
conn.SetWriteDeadline(...)Without deadlines, a stuck peer can block a goroutine forever.
Line-oriented echo pattern
func serve(conn net.Conn) {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
r := bufio.NewReader(conn)
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
if _, err := io.WriteString(conn, line); err != nil {
return
}
// extend deadline per message if desired
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
}
}Theory 4 — Temporary errors & close
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
// deadline hit
}Close unblocks pending I/O with an error. For half-close: CloseWrite on *net.TCPConn when you need FIN while still reading—advanced protocol design.
Theory 5 — net.Pipe for tests
In-memory full-duplex connection without sockets:
client, server := net.Pipe()
go func() {
defer server.Close()
_, _ = io.Copy(server, server) // silly echo; use your serve()
}()Great for unit-testing protocol framing without ports.
Worked example — package echo
package echo
import (
"bufio"
"context"
"fmt"
"io"
"net"
"time"
)
type Server struct {
ln net.Listener
}
func Listen(addr string) (*Server, error) {
ln, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
s := &Server{ln: ln}
go s.loop()
return s, nil
}
func (s *Server) Addr() string { return s.ln.Addr().String() }
func (s *Server) Close() error { return s.ln.Close() }
func (s *Server) loop() {
for {
conn, err := s.ln.Accept()
if err != nil {
return
}
go handle(conn)
}
}
func handle(conn net.Conn) {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
br := bufio.NewReader(conn)
for {
line, err := br.ReadBytes('\n')
if len(line) > 0 {
if _, werr := conn.Write(line); werr != nil {
return
}
}
if err != nil {
return
}
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
}
}
func Echo(ctx context.Context, addr, msg string) (string, error) {
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return "", err
}
defer conn.Close()
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
if _, err := io.WriteString(conn, msg); err != nil {
return "", err
}
br := bufio.NewReader(conn)
line, err := br.ReadString('\n')
if err != nil {
return "", fmt.Errorf("read: %w", err)
}
return line, nil
}Lab 1 — Setup
mkdir -p ~/lab/90daysofx/01-go/day44
cd ~/lab/90daysofx/01-go/day44
go mod init example.com/day44Lab 2 — Integration test on localhost
func TestEcho(t *testing.T) {
s, err := Listen("127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
got, err := Echo(ctx, s.Addr(), "ping\n")
if err != nil {
t.Fatal(err)
}
if got != "ping\n" {
t.Fatalf("got %q", got)
}
}go test ./...Lab 3 — Concurrent clients
Spawn 20 goroutines each sending unique lines. Use errgroup (from Stage III) or sync.WaitGroup + error channel. Assert all echoes match.
Lab 4 — Deadline failure
Set a very short read deadline on a client that dials but never receives a newline from a hung peer (write a “slow” server that sleeps). Assert timeout error.
Lab 5 — Manual demo
go run ./cmd/echod -addr 127.0.0.1:9000
# other terminal:
printf 'hello\n' | nc 127.0.0.1 9000Theory note — from TCP to HTTP
HTTP/1.1 is text framed on a net.Conn: request line, headers, body. net/http manages connection pools, parsing, and TLS. After today you should be able to sketch where Accept sits under http.Server.Serve.
Common gotchas
| Gotcha | Fix |
|---|---|
| No dial timeout | Dialer{Timeout} / DialContext |
| No read deadline | SetDeadline / SetReadDeadline |
Forgetting \n in line protocol |
Document framing |
| Accept loop dies on one error | Log temporary; exit on close |
| Goroutine per conn without limits | Bound concurrency later |
Testing on :8080 fixed |
Use :0 + Addr() |
IPv6/localhost surprises |
Prefer 127.0.0.1 in tests |
Checkpoint
- Echo server listens on ephemeral port
- Client round-trip tested
- Deadlines demonstrated
- Concurrent clients succeed
- Can explain
net.Connvshttp.ResponseWriterrelationship
- Listener closed in
t.Cleanup
Commit
git add .
git commit -m "day44: TCP echo server and client with deadlines"Write three personal gotchas before continuing.
Tomorrow
Day 45 — net/http server: Handler, Go 1.22+ ServeMux patterns, middleware chains, and a small JSON API.