Skip to content
VLSI Mentor

Tcl · Module 14

try and finally

A file opened on the success path and closed on the success path is a leak. Learn the construct that guarantees cleanup on every exit — and the portable form to use when your interpreter predates it.

Intermediate17 min readtcltryfinallycleanupcatcherror-handling

Part I · Module 14 · Chapter 14.5 · try and finally

Chapter 14.3 gave you a way to survive an error, and 14.4 gave you the codes catch reports. Both leave one problem open. When a body fails halfway through, everything it had already acquired — an open channel, a temporary file, a changed working directory, a saved setting — is still acquired, and the code that would have released it sat on the line after the one that failed.

This chapter is about closing that gap: making a release happen on every way out of a block, not just the happy one.

1. The leak

Here is the shape, and it is the single most common resource bug in Tcl scripts:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
proc process {fp} { error "unparseable line 3" }

proc leaky {path} {
    set fp [open $path r]
    process $fp
    close $fp
}

close is the last line, so it runs only if process returns normally. Count the open channels before and after to see what that costs:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
proc leftovers {} {
    set n 0
    foreach c [file channels] { if {![string match std* $c]} { incr n } }
    return $n
}

puts "  files open before: [leftovers]"
catch {leaky $f} msg
puts "  error            : $msg"
puts "  files open after : [leftovers]   <- the leak"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  files open before: 0
  error            : unparseable line 3
  files open after : 1   <- the leak

The error was caught, the script carried on, and a channel is still open. One leak is harmless. The same procedure called in a loop over four hundred report files, a tenth of which are malformed, exhausts the file-descriptor limit and then fails with too many open files — an error pointing at a line that has nothing wrong with it.

2. The portable fix: catch, then release, then re-raise

The repair has three parts, and all three matter:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
proc careful {path} {
    set fp [open $path r]
    set rc [catch {process $fp} result opts]
    close $fp
    return -options $opts $result
}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  files open before: 0
  catch saw        : 1
  message          : unparseable line 3
  files open after : 0
  1. catch so the failure cannot skip the release. The body's outcome is captured in rc, result and opts instead of unwinding past close.
  2. close unconditionally, on both paths.
  3. return -options $opts $result to put the outcome back exactly as it was.

That third line is the part people leave out. return -options $opts $result re-raises with the original code and the original options dictionary — the -errorcode and the accumulated -errorinfo from Chapter 14.6 travel with it. Writing error $result instead would throw the stack trace away and restart it here, which is why the caller of a badly written wrapper always sees the wrapper as the origin of every error.

3. Cleanup must run on every exit, not just error

"Success or error" is the wrong mental model — there are five completion codes, and a body can leave by any of them. Factor the pattern into a helper and watch which paths print release:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
proc body_that {how} {
    switch -- $how {
        ok   { return "a value" }
        fail { error "it broke" }
        brk  { break }
        ret  { return -code return "early" }
    }
}

proc with_cleanup {how} {
    puts "    acquire"
    set rc [catch {body_that $how} result opts]
    puts "    release"
    return -options $opts $result
}

foreach how {ok fail brk ret} {
    puts "  how=$how"
    set rc [catch {with_cleanup $how} r]
    puts "    caller saw code $rc, result '$r'"
}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  how=ok
    acquire
    release
    caller saw code 0, result 'a value'
  how=fail
    acquire
    release
    caller saw code 1, result 'it broke'
  how=brk
    acquire
    release
    caller saw code 1, result 'invoked "break" outside of a loop'
  how=ret
    acquire
    release
    caller saw code 0, result 'early'

release appears four times out of four. That is the property you are buying.

Two of those rows repay a second look:

  • brk became an error: invoked "break" outside of a loop. The break escaped body_that as code 3, found no enclosing loop at the procedure boundary, and was converted. The cleanup still ran — which is the point — but a break in a procedure body is a bug in its own right.
  • ret reached the caller as code 0 with the result early. return -code return arrived at with_cleanup's catch as code 2, was re-raised faithfully, and then with_cleanup's own procedure boundary consumed it — exactly the -level mechanism from 14.4 §6. The value survived; the control flow was absorbed one frame later than you might expect.

4. try and finally — and whether you have them

Tcl 8.6 added a command that expresses all of §2 directly:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
try {
    set fp [open $path r]
    process $fp
} on error {msg} {
    puts stderr "could not process $path: $msg"
} finally {
    if {[info exists fp]} { close $fp }
}

The clauses are:

ClauseRuns when
try {body}always — the protected block
on error {msg ?opts?} {...}the body raised (code 1)
on ok / on return / on break / on continuethe body left by that specific code
trap {PATTERN} {msg ?opts?} {...}the body raised and its -errorcode starts with PATTERN
finally {...}always, after whichever handler ran

finally is the valuable half. It is what §2 and §3 were hand-building, and it is guaranteed on every exit including codes 2, 3 and 4.

But do not assume you have it. The interpreter this chapter was verified against does not:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
puts "  info commands try -> '[info commands try]'"
puts "  [catch {try {set x 1} finally {}} e] / $e"
puts "  info patchlevel -> [info patchlevel]"
puts "  8.6 or newer?    -> [expr {[package vsatisfies [info patchlevel] 8.6-] ? "yes" : "no"}]"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  info commands try -> ''
  1 / invalid command name "try"
  info patchlevel -> 8.5.9
  8.6 or newer?    -> no

5. A with_cleanup you can keep

Since the catch form is the portable one, write it once as a helper instead of open-coding it at every call site:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
# Run $body, then always run $cleanup, then re-raise whatever $body did.
proc with_cleanup {body cleanup} {
    set rc [catch {uplevel 1 $body} result opts]
    catch {uplevel 1 $cleanup}
    return -options $opts $result
}

uplevel 1 runs both scripts in the caller's scope, so they see the caller's local variables — $fp in the example below belongs to worst_in, not to with_cleanup. That is the uplevel mechanism from Module 11, and it is what makes a helper like this usable rather than a sealed box.

Note the catch around the cleanup too. That is deliberate, and §6 explains the trade-off it encodes.

Used at a call site, the resource and its release sit on adjacent lines:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
proc worst_in {name} {
    set fp [open $name r]
    return [with_cleanup { parse_slack $fp $name } { close $fp }]
}

Read that as: open it, do the work, and the channel closes no matter how the work ends.

6. When the cleanup itself fails

A close can fail — a full filesystem, a broken pipe. So a cleanup wrapper has to decide which error the caller hears. Watch both cases:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
proc with_cleanup {body cleanup} {
    set rc [catch {uplevel 1 $body} result opts]
    set crc [catch {uplevel 1 $cleanup} cresult]
    if {$crc != 0} { puts "    (cleanup itself failed: $cresult)" }
    return -options $opts $result
}

puts "  body ok, cleanup fails:"
puts "    caller saw [catch {with_cleanup {set a 1} {error "close failed"}} r], result '$r'"
puts "  body fails, cleanup fails:"
puts "    caller saw [catch {with_cleanup {error "body failed"} {error "close failed"}} r], result '$r'"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  body ok, cleanup fails:
    (cleanup itself failed: close failed)
    caller saw 0, result '1'
  body fails, cleanup fails:
    (cleanup itself failed: close failed)
    caller saw 1, result 'body failed'

The body's outcome wins in both rows, and the cleanup failure is reported but not raised.

That is the right default: the body is the work, and a diagnosis pointing at close when the real problem was a malformed input file sends the reader to the wrong place. But it is a choice, and the second row shows the cost — had the body succeeded and the close genuinely failed to flush, the caller would be told everything was fine. If the cleanup's success is part of the contract, report it loudly, as the puts above does, or raise it when the body succeeded and swallow it only when the body already failed.

7. Choosing the handler: on error versus trap

on error catches every error. trap catches only those whose -errorcode matches a prefix — which is how you handle "the file was not there" differently from "the file was there and the contents were wrong". Real failures carry useful codes:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
foreach {label body} {
    {open a missing file}   {open /nonexistent/zz r}
    {divide by zero}        {expr {1/0}}
    {bad variable}          {set y $undefinedvar}
    {your own error}        {error "plain message"}
    {error with a code}     {error "no such port: clk" {} {PORT MISSING clk}}
} {
    catch $body r opts
    puts [format "  %-22s %s" $label [dict get $opts -errorcode]]
}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  open a missing file    POSIX ENOENT {no such file or directory}
  divide by zero         ARITH DIVZERO {divide by zero}
  bad variable           NONE
  your own error         NONE
  error with a code      PORT MISSING clk

System failures are classified for you; a plain error is NONE until you supply a third argument. The catch-based equivalent of trap is a switch on the first element:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
proc classify {body} {
    set rc [catch {uplevel 1 $body} result opts]
    if {$rc == 0} { puts "  ok: $result" ; return }
    set code [dict get $opts -errorcode]
    switch -- [lindex $code 0] {
        POSIX   { puts "  filesystem problem: $result" }
        ARITH   { puts "  arithmetic problem: $result" }
        PORT    { puts "  port problem ([lindex $code 2]): $result" }
        default { puts "  unclassified: $result" }
    }
}

classify { expr {2+2} }
classify { open /nonexistent/zz r }
classify { expr {1/0} }
classify { error "no such port: clk" {} {PORT MISSING clk} }
classify { error "something else" }
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  ok: 4
  filesystem problem: couldn't open "/nonexistent/zz": no such file or directory
  arithmetic problem: divide by zero
  port problem (clk): no such port: clk
  unclassified: something else

This is strictly better than matching on message text with string match. A message is prose that a future Tcl release may reword; POSIX ENOENT is a contract. And because the code is a list, [lindex $code 2] pulls the offending value straight out — clk above — without parsing the sentence it appears in.

8. Worked example: a batch reader that leaks nothing

Four report files, one malformed, one missing. Every channel must close, every failure must be classified, and the run must finish:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
proc with_cleanup {body cleanup} {
    set rc [catch {uplevel 1 $body} result opts]
    catch {uplevel 1 $cleanup}
    return -options $opts $result
}

proc parse_slack {fp name} {
    set worst 99.0
    while {[gets $fp line] >= 0} {
        if {[llength $line] != 2} { error "malformed line in $name: '$line'" }
        lassign $line pin value
        if {![string is double -strict $value]} {
            error "non-numeric slack in $name: '$value'" {} {RPT BADVALUE $value}
        }
        if {$value < $worst} { set worst $value }
    }
    return $worst
}

proc worst_in {name} {
    set fp [open $name r]
    return [with_cleanup { parse_slack $fp $name } { close $fp }]
}

set ok 0 ; set bad 0
foreach name {a.rpt b.rpt c.rpt missing.rpt} {
    set rc [catch {worst_in $name} result opts]
    if {$rc == 0} {
        puts [format "  %-12s worst slack %.2f" $name $result]
        incr ok
    } else {
        switch -- [lindex [dict get $opts -errorcode] 0] {
            POSIX   { puts [format "  %-12s unreadable: %s" $name $result] }
            RPT     { puts [format "  %-12s rejected:   %s" $name $result] }
            default { puts [format "  %-12s failed:     %s" $name $result] }
        }
        incr bad
    }
}
puts "  ---"
puts "  $ok parsed, $bad rejected, [leftovers] channels left open"

With a.rpt and c.rpt well formed, b.rpt containing reset bad, and missing.rpt absent:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  a.rpt        worst slack 1.07
  b.rpt        rejected:   non-numeric slack in b.rpt: 'bad'
  c.rpt        worst slack 0.88
  missing.rpt  unreadable: couldn't open "missing.rpt": no such file or directory
  ---
  2 parsed, 2 rejected, 0 channels left open

Four things are worth naming in that output:

  • 0 channels left open — the leak from §1 is gone, and the open in worst_in succeeded twice for files whose parse then failed.
  • missing.rpt never reached with_cleanup at all. open itself failed, so there was nothing to close; the POSIX arm caught it one level up.
  • b.rpt was rejected by its -errorcode, not its wording — RPT BADVALUE bad. Rewording the message changes nothing about the dispatch.
  • The loop finished. A single bad input did not take the batch down, which is the whole reason this machinery exists.

9. Exercises

Exercise 1. Why is close $fp on the line after process $fp not a cleanup?

Exercise 2. What does return -options $opts $result preserve that error $result destroys?

Exercise 3. In §3, brk produced the error invoked "break" outside of a loop. Did the cleanup still run, and why?

Exercise 4. Your script must run on an unknown vendor shell. Write the one-line test that tells you whether try is available.

Exercise 5. Why is dispatching on -errorcode more durable than string match on the message?

Exercise 6. In §6, the body succeeded and the cleanup failed, and the caller saw code 0. When is that the wrong choice?

Where this fits

Part of the Tcl curriculum.