Case Study 2: Hardening the Input Path
"Every serious bug I have chased in a scientific code started as a value that entered the program without being checked."
Executive Summary
Where the first case study diagnosed a failure after the fact, this one engineers failures out of existence at the place they most often enter a scientific code: the input boundary. We design and build a robust configuration loader for the heat solver — a module that reads the namelist, distinguishes a missing file from a malformed one from an out-of-range value, reports each with a specific message and a specific exit code, and makes its most expensive checks removable for production. This is defensive programming (§13.5) raised from a habit to an architecture. By the end you will have a reusable pattern for the boundary between your program and everything it does not control.
Skills applied: generalizing iostat and stat (§13.1); error stop and an exit-code taxonomy (§13.2);
assertions, preconditions, and removable checks (§13.5); the input-validation half of the Project Checkpoint;
reporting to error_unit with context.
Background
A configuration loader has to survive a hostile world. The file may not exist; it may exist but have a
misspelled group name or key; it may parse cleanly yet contain values that are physically impossible
(nx = 0, a negative dt). Each of these is a different failure that deserves a different response, and
a loader that collapses them into one generic "bad input" is only marginally better than one that crashes.
Our design goal: every way the input can be wrong maps to a specific message and a specific exit code, and
no invalid value ever reaches the solver. We build it in layers.
Phase 1 — An Error Taxonomy and an Exit-Code Contract
Before writing code, name the failures and assign each an exit code. This contract is what lets a wrapper script or CI job react without parsing text (§13.2).
| Failure | Example | Exit code | Message names… |
|---|---|---|---|
| File cannot be opened | file missing, no permission | 2 |
the filename and the OS reason (iomsg) |
| Namelist cannot be parsed | wrong group name, misspelled key | 3 |
that the parse failed and the iomsg |
| Value out of range | nx = 0, dt <= 0, alpha <= 0 |
4 |
the offending parameter and its value |
Three failure modes, three codes, three distinct messages. The taxonomy is the design; the code is its implementation.
Phase 2 — Bundle the Configuration in a Type
We give the configuration a config_t derived type (Chapter 9), so
it travels as one self-describing object rather than five loose variables:
module config_mod
use, intrinsic :: iso_fortran_env, only: dp => real64, error_unit
implicit none
private
public :: config_t, load_config
type :: config_t
integer :: nx = 0, ny = 0, n_steps = 0
real(dp) :: dt = 0.0_dp, alpha = 0.0_dp
end type config_t
logical, parameter :: checking = .true. ! flip to .false. to compile out the asserts
contains
The checking parameter is the switch that will make our assertions vanish in a production build (Phase 4).
Phase 3 — The Guarded Reader
Now the loader itself, guarding each operation that can fail with the pattern of §13.1 and mapping each
failure to its code from Phase 1. Open guarded by iostat; parse guarded by iostat; then hand off to
validation:
subroutine load_config(filename, cfg)
character(len=*), intent(in) :: filename
type(config_t), intent(out) :: cfg
integer :: u, ios
character(len=256) :: msg
integer :: nx, ny, n_steps
real(dp) :: dt, alpha
namelist /config/ nx, ny, alpha, dt, n_steps
! Defaults, so an omitted key keeps a known (and invalid) value we can catch.
nx = 0; ny = 0; alpha = 0.0_dp; dt = 0.0_dp; n_steps = 0
open(newunit=u, file=filename, status='old', action='read', iostat=ios, iomsg=msg)
if (ios /= 0) then ! FAILURE 1: cannot open
write(error_unit, '(a)') 'config: cannot open ' // trim(filename) // ': ' // trim(msg)
error stop 2
end if
read(u, nml=config, iostat=ios, iomsg=msg)
if (ios /= 0) then ! FAILURE 2: cannot parse
write(error_unit, '(a)') 'config: cannot parse ' // trim(filename) // ': ' // trim(msg)
close(u)
error stop 3
end if
close(u)
cfg = config_t(nx=nx, ny=ny, n_steps=n_steps, dt=dt, alpha=alpha)
call validate(cfg) ! FAILURE 3 lives here
end subroutine load_config
Note the discipline: defaults are set to invalid values (nx = 0), so a key the file forgets to mention is
caught by validation rather than silently accepted — the opposite of the Chapter 1 case study's advice to
never trust an unset value. The iomsg text is shown to the human but never branched upon; we branch on the
integer ios.
Phase 4 — Validation with Removable Assertions
Validation is where out-of-range values are rejected. We use the assert idea (§13.5), but wrapped in the
checking switch so a production build can compile the checks out entirely:
subroutine validate(cfg)
type(config_t), intent(in) :: cfg
if (cfg%nx < 1 .or. cfg%ny < 1) then
write(error_unit, '(a, i0, a, i0)') 'config: grid must be positive, got ', cfg%nx, ' x ', cfg%ny
error stop 4
end if
if (cfg%dt <= 0.0_dp) then
write(error_unit, '(a, es10.2)') 'config: dt must be positive, got ', cfg%dt
error stop 4
end if
if (cfg%alpha <= 0.0_dp) then
write(error_unit, '(a, es10.2)') 'config: alpha must be positive, got ', cfg%alpha
error stop 4
end if
! Cheap, always-on checks above stay; expensive invariants can be gated:
if (checking) then
if (cfg%n_steps < 1) then
write(error_unit, '(a, i0)') 'config: n_steps must be positive, got ', cfg%n_steps
error stop 4
end if
end if
end subroutine validate
end module config_mod
The always-on checks (grid, dt, alpha) are cheap and guard against catastrophe, so they stay in every
build. Costlier invariants — imagine one that scans a large table — go behind if (checking), which the
optimizer deletes when checking is .false.. This mirrors §13.3's rule for -fcheck: exhaustive while
developing, lean for production.
Phase 5 — Drive It and Verify
A driver writes a valid config, loads it through the hardened path, and echoes the validated result:
program harden_demo
use config_mod, only: config_t, load_config
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
type(config_t) :: cfg
integer :: u
open(newunit=u, file='heat.nml', status='replace', action='write')
write(u, '(a)') '&config'
write(u, '(a)') ' nx = 8, ny = 8,'
write(u, '(a)') ' alpha = 1.0e-4, dt = 0.2,'
write(u, '(a)') ' n_steps = 500'
write(u, '(a)') '/'
close(u)
call load_config('heat.nml', cfg) ! guarded open + parse + validate
print '(a)', 'config loaded and validated:'
print '(a, i0)', ' nx = ', cfg%nx
print '(a, i0)', ' ny = ', cfg%ny
print '(a, f0.3)', ' dt = ', cfg%dt
print '(a, es9.2)', ' alpha = ', cfg%alpha
print '(a, i0)', ' n_steps = ', cfg%n_steps
print '(a)', 'all checks passed.'
end program harden_demo
$ gfortran -std=f2018 -Wall -O2 config_mod.f90 harden_demo.f90 -o harden && ./harden
config loaded and validated:
nx = 8
ny = 8
dt = 0.200
alpha = 1.00E-04
n_steps = 500
all checks passed.
The valid config passes all three layers, so the driver echoes it: the grid is $8\times8$, dt prints as
0.200 (f0.3), alpha as 1.00E-04 (es9.2), n_steps as 500. Now consider the failure paths, which
we describe rather than run (their messages are representative):
- A missing
heat.nml→config: cannot open heat.nml: No such file or directory, thenerror stop 2. - A file with
¶msinstead of&config, orn_stepinstead ofn_steps→ a parse failure,config: cannot parse heat.nml: …, thenerror stop 3. - A file with
nx = 0→config: grid must be positive, got 0 x 8, thenerror stop 4.
Three different mistakes, three different exit codes, three messages that name the problem. A wrapper reading
$? knows which thing went wrong without reading a word.
Discussion Questions
- The loader sets its defaults to invalid values (
nx = 0) on purpose. What failure mode does that choice catch that sensible defaults (nx = 100) would silently hide? When might sensible defaults nonetheless be the right call? - We gave "cannot open," "cannot parse," and "out of range" three different exit codes. Construct a concrete scenario — a nightly batch job, say — where collapsing them into a single nonzero code would cost real debugging time.
- The
checkingparameter compiles some checks out of production. Which checks did we deliberately keep always on, and what principle decides whether a given check may be gated or must stay?
Your Turn: Extensions
- Option A. Add a fourth failure class: a value that parses and is in range but is physically
suspicious — e.g. an
alphaa thousand times any real material's. Decide whether it should be a harderror stopor a warning toerror_unitthat lets the run proceed, and justify the choice. - Option B. Refactor
load_configto return astatargument instead of callingerror stopitself, so the caller decides whether a failure is fatal. Discuss the tradeoff: a library that halts the program vs. one that reports and lets the caller choose. (This is the design question behind every error-returning API.) - Option C. Write the namelist echo-back: on a successful load,
write(u, nml=config)the validated configuration to the top of the output file, so every result documents the exact inputs that produced it (the provenance habit from Chapter 7, and a cornerstone of reproducibility in Chapter 37).
Key Takeaways
- The input boundary is where most bad values enter a scientific code, so it is where defensive design pays the highest dividend. Harden it deliberately, in layers: open, parse, validate.
- An error taxonomy — naming each failure and giving it a distinct exit code and message — turns "bad input" into an actionable diagnosis a script can branch on. Three codes beat one every time.
- Set defaults to invalid values so an omitted parameter is caught, not silently accepted; branch on the integer status, show the string message to a human.
- Make expensive checks removable behind a
checkingparameter (or the C preprocessor), keeping the cheap catastrophic ones always on — the same develop-thoroughly, ship-lean discipline as-fcheck.