Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Dependencies

The wait block controls process startup order. It contains conditions evaluated sequentially — all must pass before run executes. A job without a wait block starts immediately.

Full Example

service api {
  env DB_URL = @migrate.DATABASE_URL

  wait {
    after @migrate
    http "http://localhost:3000/health" {
      status = 200
      timeout = 30s
      poll = 500ms
    }
    connect "127.0.0.1:5432"
    exists "/tmp/ready.flag"
  }

  run "api-server start --db $DB_URL"
}

Here api waits for migrate to exit, then checks an HTTP endpoint, then waits for a TCP port, then checks for a file — all in order — before starting.

Condition Types

after @job

Wait for a job to exit successfully (exit code 0).

wait {
  after @migrate
}

A non-zero exit triggers supervisor shutdown and the condition is never satisfied. Parse-time error if the target is not a job (services do not exit, so after cannot reference them).

For for jobs, after @nodes is satisfied only when all fan-out instances have exited successfully.

http "url" { status = N }

Wait for an HTTP endpoint to return an expected status code.

wait {
  http "http://localhost:8080/health" {
    status = 200
  }
}

The HTTP client uses a 5-second per-request timeout. Only the status code is checked — the response body is ignored.

connect "host:port"

Wait for a TCP port to accept connections.

wait {
  connect "127.0.0.1:5432"
}

Each poll attempt uses a 1-second connect timeout.

!connect "host:port"

Wait until a TCP port is not accepting connections.

wait {
  !connect "127.0.0.1:8080"
}

The condition is satisfied when the connection is refused (nobody is listening). Useful to ensure a stale process has released a port before starting a replacement.

exists "path"

Wait for a file to appear on disk.

wait {
  exists "/tmp/ready.flag"
}

!exists "path"

Wait until a file does not exist on disk.

wait {
  !exists "/tmp/api.lock"
}

Useful to wait for a lockfile or PID file to be cleaned up.

!running "pattern"

Wait until no process matching a pattern is running.

wait {
  !running "old-api.*"
}

Uses pgrep -f which matches against the full command line. Available on both macOS and Linux. There is no positive running form — “wait until a process is running” is inherently racy; use connect or http for readiness checks instead.

contains "path" { ... }

Wait for a file to contain a specific key, with optional value extraction into a job-scoped variable.

wait {
  contains "/tmp/config.yaml" {
    format = "yaml"
    key = "$.database.url"
    var = database_url
  }
}
FieldRequiredDescription
formatyes"json" or "yaml"
keyyesJSONPath expression (RFC 9535)
varnoIf set, the resolved value is bound to this job-scoped variable

The key field accepts a JSONPath expression. Use $ to refer to the document root, . to traverse nested maps, and bracket notation for array filtering. The first matching value is used. Scalar values (strings, numbers, booleans) are converted to strings. Null values are treated as missing. Mappings and sequences are serialized as JSON strings.

Array filtering example — extract rpc from the entry where alias == "local":

wait {
  contains "/tmp/sui_client.yaml" {
    format = "yaml"
    key = "$.envs[?(@.alias == 'local')].rpc"
    var = sui_rpc_url
  }
}

env SUI_RPC_URL = sui_rpc_url

output_matches @job "pattern"

Wait for an upstream job or service to emit a line containing pattern on its captured output stream. The match is a literal substring (not a regex), case-sensitive, evaluated per line. ANSI color escapes are stripped before matching, so patterns work against colorized output.

service api {
  wait {
    output_matches @migrate "Migrations complete."
  }
  run "api-server"
}
FieldRequiredDescription
timeoutnoDuration or none. Defaults to none (wait indefinitely).

Differences from other conditions:

  • The matcher is event-driven, not polled — poll = ... is rejected at parse time.
  • A stream match either happens or it doesn’t — retry = ... is rejected at parse time.
  • Negation (!output_matches ...) is not supported and rejected at parse time.

Pre-spawn registration prevents missed signals. Matchers are registered before any process is spawned, so if the upstream emits the pattern before the downstream waiter actually reaches the output_matches step (e.g. when output_matches follows another condition like after @setup), the match is latched and the waiter releases immediately when it gets there. There is no race window between upstream startup and the waiter reaching the condition.

Upstream exits without match. If the upstream’s output stream reaches EOF without the pattern ever being observed, the waiter logs:

dependency failed: output_matches @upstream "pattern" (upstream exited, pattern never observed)

and triggers shutdown — same shape as after @job failing.

Allowed targets. The target must be a job or service. task and event targets are rejected at validate time. Self-references and unknown targets are also rejected. Output_matches edges contribute to cycle detection alongside after edges.

Fan-out upstreams (for ... in ...): when the upstream is a fan-out template, the matcher is copied to each materialized instance. Any one instance emitting the pattern satisfies the condition (first-wins).

Interpolation. The pattern supports ${args.NAME}, ${module.dir}, ${procman.dir}, and ${alias::args.NAME} interpolation, consistent with other string-bearing conditions:

service api {
  wait {
    output_matches @worker "${args.phase_token}"
  }
  run "serve"
}

Not yet supported (planned): regex pattern syntax and capture-group extraction into a var. For v1, only literal substring matching is available.

Condition Options

Any condition can have a sub-block with options:

OptionDefaultDescription
timeoutnoneDuration before giving up. none means wait indefinitely.
poll1s (100ms for after)Duration between checks
retrytruefalse = fail immediately on first check
wait {
  connect "127.0.0.1:5432" {
    timeout = 10s
    retry = false
  }
  after @migrate {
    timeout = 30s
  }
}

Use an explicit timeout = 30s (or any duration) when you want a bounded wait; the default is unbounded.

No-retry mode

When retry = false, the condition is checked exactly once. If it is not satisfied on the first check, procman logs dependency failed (retry disabled): <description> and triggers shutdown immediately, without polling or waiting for a timeout.

This is useful to catch stale state that should have been cleaned up before procman started: leftover lock files, ports still bound by a previous run, or zombie processes.

wait {
  !exists "/tmp/api.lock" {
    retry = false
  }
}

String Interpolation

String arguments to wait conditions (connect, http, exists, contains, !running) support ${args.NAME} interpolation. The built-in ${module.dir} and ${procman.dir} variables are also available. For imported modules, use ${alias::args.NAME} and ${alias::module.dir}.

arg working_dir { type = string default = "/tmp" }
service api {
  wait { exists "${args.working_dir}/config.yaml" }
  run "start"
}

var Binding

The contains condition can extract a value into a job-scoped variable, referenced in env bindings. The two-step pattern:

service api {
  wait {
    contains "/tmp/config.yaml" {
      format = "yaml"
      key = "$.database.url"
      var = database_url
    }
  }

  env DB_URL = database_url
  run "start-api --db $DB_URL"
}

The variable is scoped to the enclosing job (not to the wait block), so it can be referenced in env bindings anywhere in the job body. It follows the same no-shadowing rules as for iteration variables — shadowing any existing name (args, other locals, other var bindings) is a parse-time error.

Note: var bindings are procman expressions, not direct env injections. They enter the environment only when explicitly assigned via env KEY = var_name.

Evaluation Order

Conditions within a wait block are evaluated sequentially in declaration order. Each condition is fully satisfied before the next one is checked:

  1. Start with the first condition.
  2. Poll the current condition using its check function.
  3. If the check succeeds, log dependency satisfied: <description> and advance to the next condition.
  4. If the check fails for the first time and retry is false, log dependency failed (retry disabled): <description> and trigger shutdown.
  5. Otherwise, if the check fails for the first time, log dependency not ready: <description> (logged only once per condition to avoid noise).
  6. If the check fails, sleep for the condition’s poll interval and retry.
  7. Once all conditions are satisfied, proceed to spawn the process.

This sequential evaluation prevents stale-data races — for example, a contains condition listed after an after condition will not be checked until the upstream job has actually exited, ensuring it reads freshly generated data rather than leftovers from a prior run.

Timeout Behavior

Each condition’s timeout clock starts when that condition begins being evaluated (i.e., when the previous condition is satisfied), not when the waiter thread starts. This means total wall-clock time for a job with multiple conditions is the sum of individual wait times rather than the maximum.

If any single condition exceeds its timeout:

  1. The waiter logs dependency timed out: <description>.
  2. The global shutdown flag is set.
  3. All processes are torn down (SIGTERM, then SIGKILL after a grace period).

A timed-out condition is fatal — procman does not continue with partial dependencies.

Circular Dependency Detection

At parse time, procman builds a directed graph from after references and runs a DFS cycle detection pass. If a cycle is found, parsing fails with an error showing the full cycle path:

Error: circular dependency: a -> b -> c -> a

Self-dependencies (a -> a) are also detected. References to job names not defined in the config file are rejected with:

Error: process 'a' depends on unknown process 'nonexistent'