Tcl · Module 27
Batch Entry Points
One script decides where the flow tree is, where output goes, where the log goes and what the exit status means. Getting that right takes three fixes, and the second one looks like the whole job.
Intermediate21 min readtcledapart-iiinfrastructurebatchautomation
Part II · Module 27 · Chapter 27.1 · Batch Entry Points
Module 27 is about the shared Tcl a project lives on. It starts with the one script everything else hangs off: the entry point that a human, a Makefile or a job scheduler invokes.
Its job is to leave nothing to the caller.
1. What "decided by the script" means
A driver that works when you run it from the right directory with the right environment is not a driver; it is a note to yourself. The entry point should decide:
- Where the flow tree is — so it can find its own libraries.
- Where output goes — one directory per design and stage, created by the script.
- Where the log goes — inside that directory, not wherever stdout happened to point.
- What the exit status means — distinct codes for distinct outcomes.
Everything in this chapter is a way of getting one of those wrong.
2. Depending on the launch directory
# flow/bin/run_naive.tcl
source flow/lib/vars.tcl=== launched from the flow root ===
--- a driver that assumes the launch directory ---
cwd : /private/tmp/m27
lib dir : flow/lib (relative)
source : ok
--- a driver that locates itself ---
cwd : /private/tmp/m27
info script: flow/bin/run_safe.tcl
normalized : /private/tmp/m27/flow/bin/run_safe.tcl
FLOW_ROOT : /private/tmp/m27/flow
source : ok
=== launched from somewhere else ===
--- a driver that assumes the launch directory ---
cwd : /private/tmp
lib dir : flow/lib (relative)
source : FAILED — couldn't read file "flow/lib/vars.tcl": no such file or directory
--- a driver that locates itself ---
cwd : /private/tmp
info script: /private/tmp/m27/flow/bin/run_safe.tcl
normalized : /private/tmp/m27/flow/bin/run_safe.tcl
FLOW_ROOT : /private/tmp/m27/flow
source : okThe fix is to ask Tcl where the running script is:
set SCRIPT [file normalize [info script]]
set BIN_DIR [file dirname $SCRIPT]
set FLOW_ROOT [file dirname $BIN_DIR]info script returns the path of the file currently being evaluated (Chapter 14.10), so a script can locate itself without being told. Note the second output block: info script returned a relative path when invoked relatively and an absolute one when invoked absolutely — it reports what was typed. That is why §3 exists.
3. Why file normalize is load-bearing
Drop the normalize and the driver still appears to work:
=== without file normalize, launched by a relative path ===
info script : m27/flow/bin/run_nonorm.tcl
FLOW_ROOT : m27/flow
absolute? : NO — relative
source now : ok
after cd to : /private/tmp/m27/work/sub
source again: FAILED — couldn't read file "m27/flow/lib/log.tcl": no such file or directory=== with file normalize, the same invocation ===
info script : m27/flow/bin/run_norm.tcl
FLOW_ROOT : /private/tmp/m27/flow
absolute? : yes
source now : ok
after cd to : /private/tmp/m27/work/sub
source again: ok4. Locating yourself is necessary and not sufficient
With FLOW_ROOT correct, the driver's own source calls work from anywhere. So does it work from anywhere?
=== the driver locates ITSELF correctly, so FLOW_ROOT is right ===
12:set ::FLOW_ROOT [file dirname [file dirname [file normalize [info script]]]]
=== but the paths it HANDS TO THE TOOL are still relative ===
62: ::tool::read_design [list rtl/$::DESIGN.sv] $::DESIGN
65: ::tool::apply_constraints constraints/$::DESIGN.sdc
=== so: run from the flow root, then from elsewhere ===
from the flow root : exit 0
from an unrelated dir : exit 1The fix derives a project root as well, and every handed-on path from it:
set ::FLOW_ROOT [file dirname [file dirname [file normalize [info script]]]]
set ::PROJ_ROOT [file dirname $::FLOW_ROOT]
...
::tool::read_design [list [file join $::PROJ_ROOT rtl $::DESIGN.sv]] $::DESIGN
::tool::apply_constraints [file join $::PROJ_ROOT constraints $::DESIGN.sdc]=== with every path derived from the root ===
13:set ::PROJ_ROOT [file dirname $::FLOW_ROOT]
66: ::tool::read_design [list [file join $::PROJ_ROOT rtl $::DESIGN.sv]] $::DESIGN
69: ::tool::apply_constraints [file join $::PROJ_ROOT constraints $::DESIGN.sdc]
from the flow root : exit 0
from an unrelated dir : exit 0
from the root directory : exit 0Three launch directories, including / itself, all exit 0. And that is the test: a driver is directory-independent when you have run it from somewhere absurd and it behaved identically, not when you have read it and concluded it should.
5. The output directory
set ::OUT_DIR [file normalize [file join $::FLOW_ROOT .. $::OUT_DIR $::DESIGN $::STAGE]]
ensure_dir $::OUT_DIR
set LOG [file join $::OUT_DIR run.log]
::flow::log_open $LOG=== and the outputs, all three ===
work/core_top/place/run.log
work/core_top/route/run.log
work/core_top/synth/run.logThree decisions worth naming.
The path includes the design and the stage. That is Chapter 26.6 §1's identity argument and Chapter 25.5 §3's collision: a directory named after fewer fields than the run collides, and the collision overwrites evidence silently.
The script creates it. ensure_dir rather than assuming — Chapter 27.7's subject, and the reason a re-run does not fail on a directory that already exists.
The log goes inside it. Not to stdout, not to /tmp, not to wherever the caller redirected. The run's output and the run's log are one artefact, and separating them is how you end up with a report directory nobody can account for.
6. The log a run leaves behind
=== the log a run leaves behind ===
+0.000s INFO design core_top stage synth
+0.006s INFO started 2026-10-08 23:07:11
+0.018s INFO tcl 8.5.9 host macbook-air.local
+0.018s INFO flow root /private/tmp/m27/flow
+0.018s INFO out dir /private/tmp/m27/work/core_top/synth
+0.018s INFO step setup: start
+0.018s INFO step setup: ok in 0 ms
+0.018s INFO step read_design: start
+0.018s INFO step read_design: ok in 0 ms
+0.018s INFO step constrain: start
+0.018s INFO step constrain: ok in 0 ms
+0.018s INFO step optimise: start
+0.018s INFO step optimise: ok in 0 ms
+0.018s INFO step report: start
+0.018s INFO step report: ok in 0 ms
+0.018s INFO flow completedThe header carries the provenance — design, stage, wall-clock start, interpreter version, host, flow root, output directory — which is Chapter 24.2 §6's argument for the third time in Part II: a result whose origin is unrecorded cannot be compared with anything.
Every step is 0 ms because the tool here is a mock that does nothing. That is honest rather than unfortunate: a runtime report of a mock flow is uninformative, which is why Chapter 27.6 demonstrates timing on a flow that does real work.
The stamps are elapsed time, not wall clock — +0.011s rather than 23:08:50. Chapter 27.6 §3 argues that choice.
7. One exit status per outcome
=== one exit status per outcome ===
a complete run exit 0
a required variable missing exit 2
run_flow.tcl: 1 required variable(s) not set:
STAGE synth | place | route
an unknown option exit 2
unknown option '-desgin'
usage: run_flow.tcl -design NAME -stage STAGE [-out DIR] [-verbose 0|1]
an unknown tool backend exit 2
run_flow.tcl: unknown backend 'gamma'
a step that fails: no SDC exit 12 for a setup problem, 1 for a flow failure, 0 for success. That split is Chapter 25.4 §7's argument: a failing flow belongs to the design team, and a typo'd option, a missing variable or an unknown tool belongs to whoever launched it. One status for both routes every failure to the wrong queue.
8. The shape of the entry point
# 1. locate ourselves, before anything else
set ::FLOW_ROOT [file dirname [file dirname [file normalize [info script]]]]
set ::PROJ_ROOT [file dirname $::FLOW_ROOT]
# 2. read the command line into the variable contract
foreach {opt val} $::argv { switch -- $opt { ... } }
# 3. the shared setup, then validate the contract
source [file join $::FLOW_ROOT lib setup.tcl]
if {[catch {::flow::validate_vars} e]} { puts stderr "run_flow.tcl: $e" ; exit 2 }
# 4. decide the output and log locations, absolutely
set ::OUT_DIR [file normalize [file join $::FLOW_ROOT .. $::OUT_DIR $::DESIGN $::STAGE]]
ensure_dir $::OUT_DIR
::flow::log_open [file join $::OUT_DIR run.log]
# 5. the flow itself, inside one catch
set code [catch { ::flow::step ... } err]
# 6. one exit status per outcome
if {$code != 0} { ::flow::log ERROR "flow failed: $err" ; ::flow::log_close ; exit 1 }
::flow::log_close
exit 0The ordering is the content. Locate, parse, validate, decide paths, run, report. Each stage depends only on the ones above it, and nothing before stage 5 touches the design — so every failure up to that point is a flow problem reported as one.
Note ::flow::log_close on both exit paths. A channel left open at exit is usually flushed by the runtime, but relying on that is Chapter 12.6's mistake, and Chapter 27.8 makes it a review item.
9. Exercises
Exercise 1. What four things should a batch entry point decide rather than inherit?
Exercise 2. Why does info script sometimes return a relative path?
Exercise 3. Without file normalize, the first source succeeded and a later one failed. Explain the sequence.
Exercise 4. Why is that worse than failing immediately?
Exercise 5. The driver computed FLOW_ROOT correctly and still failed from another directory. Why?
Exercise 6. Why did that defect only appear once the constraint step checked its file?
Exercise 7. What is the test for directory independence?
Exercise 8. Why does the output directory name include the design and the stage?
Exercise 9. Why is a missing option exit 2 and a failed step exit 1?
Exercise 10. Why was ::tool::use outside the catch a defect, and what was the fix?
Where this fits
Part of the Tcl curriculum.
