Skip to content
VLSI Mentor

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
set out [exec echo "hello from echo"]
puts "result : '$out'"
puts "length : [string length $out]"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
result : 'hello from echo'
length : 15

exec 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
set listing [exec ls -1 data]
puts "raw   : '$listing'"
puts "lines : [llength [split $listing "\n"]]"
foreach l [split $listing "\n"] { puts "  - $l" }
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
raw   : 'other.txt
readings.txt'
lines : 2
  - other.txt
  - readings.txt

Only 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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]"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 : NONE

All three set catch to 1. But case C is not a failure.

The -errorcode is what tells them apart:

-errorcodeWhat actually happened
CHILDSTATUS <pid> Nthe program exited with status N — a real failure
NONEthe 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
# 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]
}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  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 catch alone flattens to 1.
  • -1 for "could not start", which is a different problem from "ran and failed": a missing tool means a broken installation or PATH, not bad input.
  • Case C stops being a failure. A command that exits 0 with stderr output returns catch 1 and -errorcode NONE, so it falls to the last return — 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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]'"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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]]'"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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]"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.txt

exec $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] then exec {*}$cmd. This is the one to use when the command is assembled conditionally.
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
set cmd [list wc -l]
if {$countWords} { set cmd [list wc -w] }
lappend cmd $path
lassign [run {*}$cmd] status output

Building 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.