Tcl · Module 15
exec: Running Another Program
Most useful scripts eventually call something else. exec does that and hands you the output — but it reports a non-zero exit status as a Tcl error, and it reports a stderr message the same way.
Intermediate19 min readtclexecexternal-programsexit-statusshellscripts
Part I · Module 15 · Chapter 15.6 · exec: Running Another Program
A Tcl script rarely does everything itself. It calls a converter, a checker, a compiler, a packager — and then has to decide what the result means. exec is how Tcl starts another program and collects what it printed.
It is a simple command with two behaviours that catch people out, and both are about how it reports trouble.
1. Running something and keeping the output
set out [exec echo "hello from echo"]
puts "result : '$out'"
puts "length : [string length $out]"result : 'hello from echo'
length : 15exec runs the program, waits for it to finish, and returns its standard output as a string.
Note the length. echo prints a trailing newline; the result is 15 characters, exactly the text. exec strips one trailing newline for you, which is almost always what you want — [exec date] or [exec whoami] can be used directly without trimming.
2. Multi-line output is still one string
set listing [exec ls -1 data]
puts "raw : '$listing'"
puts "lines : [llength [split $listing "\n"]]"
foreach l [split $listing "\n"] { puts " - $l" }raw : 'other.txt
readings.txt'
lines : 2
- other.txt
- readings.txtOnly the final newline is removed, so the internal ones are intact. split $output "\n" turns the result into a list of lines, and because the trailing newline is gone there is no empty final element to skip — a detail that saves a line of cleanup every time.
3. exec waits
exec blocks until the program finishes. A command that takes two minutes makes your script take two minutes, and a command that waits for input nobody is typing will hang forever.
That is fine for the normal case — run a tool, use its output — and it is worth knowing before you point exec at something interactive.
4. The two ways exec reports trouble
Here is the part that matters. Three situations:
puts "A. a command that does not exist:"
puts " rc=[catch {exec no-such-program-xyz} e]"
puts " $e"
puts "B. a command that exists but exits non-zero:"
set rc [catch {exec ls /no/such/dir} e opts]
puts " rc=$rc"
puts " message : $e"
puts "C. a command that SUCCEEDS but writes to stderr:"
set rc [catch {exec sh -c "echo to-stdout; echo to-stderr >&2; exit 0"} e opts]
puts " rc=$rc"
puts " result : '$e'"
puts " -errorcode : [dict get $opts -errorcode]"A. a command that does not exist:
rc=1
couldn't execute "no-such-program-xyz": no such file or directory
B. a command that exists but exits non-zero:
rc=1
message : ls: /no/such/dir: No such file or directory
-errorcode : CHILDSTATUS <pid> 1
C. a command that SUCCEEDS but writes to stderr:
rc=1
result : 'to-stdout
to-stderr'
-errorcode : NONEAll three set catch to 1. But case C is not a failure.
The -errorcode is what tells them apart:
-errorcode | What actually happened |
|---|---|
CHILDSTATUS <pid> N | the program exited with status N — a real failure |
NONE | the program succeeded but wrote to stderr |
POSIX ... | the program could not be started at all (case A) |
The third element of a CHILDSTATUS code is the real exit status — the same number Chapter 15.4 was about, arriving back in Tcl.
5. A helper that gives you the status
Rather than repeating that logic, write it once:
# Run a command. Return {status output}. status 0 means success.
proc run {args} {
set rc [catch {exec {*}$args} output opts]
if {$rc == 0} { return [list 0 $output] }
set code [dict get $opts -errorcode]
if {[lindex $code 0] eq "CHILDSTATUS"} {
return [list [lindex $code 2] $output]
}
return [list -1 $output]
} true -> status 0
false -> status 1
sh -c {exit 7} -> status 7
no-such-program -> status -1 (couldn't execute "no-such-program-xyz": no such file or directory)Three things it buys you:
- The real exit status, including 7 — which
catchalone flattens to 1. -1for "could not start", which is a different problem from "ran and failed": a missing tool means a broken installation orPATH, not bad input.- Case C stops being a failure. A command that exits 0 with stderr output returns
catch1 and-errorcode NONE, so it falls to the lastreturn— you may prefer to report 0 there and keep the text as a warning. Decide once, in this procedure, rather than at every call site.
Tcl also offers exec -ignorestderr ..., which stops stderr alone from raising, and exec ... 2>@1, which merges stderr into the captured output. Both are useful; the helper is still worth having, because neither gives you the exit status.
6. exec does not use a shell
This is the misconception worth correcting now, because it matters more later.
puts "a glob:"
puts " exec echo * -> '[exec echo *]'"
puts "a dollar sign:"
puts " exec echo \$HOME -> '[exec echo \$HOME]'"
puts "a pipe character inside one word:"
puts " exec echo a|b -> '[exec echo a|b]'"a glob:
exec echo * -> '*'
a dollar sign:
exec echo $HOME -> '$HOME'
a pipe character inside one word:
exec echo a|b -> 'a|b'No globbing. No variable expansion. exec starts the program directly, passing your words as its arguments. Nothing expanded * into filenames because nothing was there to do it.
This is good news. It means a filename containing a space, a quote or a dollar sign is passed through exactly as you have it:
exec wc -l $path -> '1 my data.txt'One Tcl word is one argument, whatever is inside it. There is no quoting to get right, because there is no shell to quote for.
7. But exec does have a few operators of its own
The picture is not quite "no shell features at all". A few characters are special to exec itself, when they appear as separate words:
puts " exec echo {a b c} | wc -w -> '[string trim [exec echo "a b c" | wc -w]]'"
exec echo "written by exec" > out.txt
puts " exec ... > out.txt -> out.txt holds '[string trim [exec cat out.txt]]'"exec HAS its own pipe and redirect operators, as separate words:
exec echo {a b c} | wc -w -> '3'
exec ... > out.txt -> out.txt holds 'written by exec'So | really does build a pipeline and > really does redirect — implemented by Tcl, not by a shell. That is why a|b as a single word stayed literal while | as its own word did not.
The accurate summary is: exec does not run a shell, but it implements a small set of shell-like operators itself. Those are |, <, >, >>, 2>, & and a few variants. Everything else a shell would do — globbing, variable expansion, &&, backticks — does not happen.
8. Never build the command as a string
The commonest exec bug:
set dir "data"
set cmd "ls -1 $dir"
puts "the string : '$cmd'"
puts "exec \$cmd : rc=[catch {exec $cmd} e] / $e"
puts "exec {*}\$cmd: rc=[catch {exec {*}$cmd} e] / [string map {\n { }} $e]"the string : 'ls -1 data'
exec $cmd : rc=1 / couldn't execute "ls -1 data": no such file or directory
exec {*}$cmd: rc=0 / other.txt readings.txtexec $cmd passed the whole string as one argument — so Tcl looked for a program literally named ls -1 data. There is no shell to split it, which is exactly §6's point arriving as a bug.
Two correct approaches:
- Write the words directly:
exec ls -1 $dir. Each Tcl word becomes one argument. - Build a list and expand it with
{*}:set cmd [list ls -1 $dir]thenexec {*}$cmd. This is the one to use when the command is assembled conditionally.
set cmd [list wc -l]
if {$countWords} { set cmd [list wc -w] }
lappend cmd $path
lassign [run {*}$cmd] status outputBuilding it with list and lappend keeps every element intact, including paths with spaces — the rule from Chapter 14.8 §2.
9. Checking that a tool is there
A script that depends on an external program should say so clearly rather than failing in the middle:
proc require_tool {name} {
if {[catch {exec which $name}]} {
error "required tool not found on PATH: $name" {} [list TOOL MISSING $name]
}
}This is the Chapter 14.11 boundary check applied to the environment rather than the data, and it pairs with the preflight idea from 14.10's have_command — one checks Tcl commands, this one checks programs.
which is itself a POSIX tool. On Windows the equivalent is where, and the portable approach in pure Tcl is to search $::env(PATH) with file executable.
10. A note on what runs where
Every command in this chapter — echo, ls, wc, true, false, sh — is a POSIX tool, and the output above was captured on macOS. On Linux you will see the same behaviour with occasional differences in message wording; ls: /no/such/dir: No such file or directory is phrased differently by GNU ls. On Windows none of those commands exist by default.
The Tcl behaviour is the same everywhere: exec starts a program, returns its stdout with one trailing newline removed, and raises on a non-zero status or on stderr output. Only the programs change.
That distinction is worth holding on to. Part II scripts run inside a vendor tool on a Linux build machine, and they exec things constantly — but what exec does is what you learned here.
11. Exercises
Exercise 1. What does exec return, and what does it do to the trailing newline?
Exercise 2. catch {exec mytool input.txt} returns 1. Name two completely different situations that produce that.
Exercise 3. Which part of the options dictionary gives the real exit status, and which element of it?
Exercise 4. exec echo * prints *. Why?
Exercise 5. Why does exec $cmd, where cmd is "ls -1 data", fail? Give two fixes.
Exercise 6. A filename contains a space. What quoting does exec ls $path need?
Exercise 7. A tool prints a deprecation warning to stderr every run and exits 0. What happens to a script using if {[catch {exec tool}]} { error ... }, and how do you fix it?
Where this fits
Part of the Tcl curriculum.
