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.nmlconfig: cannot open heat.nml: No such file or directory, then error stop 2.
  • A file with &params instead of &config, or n_step instead of n_steps → a parse failure, config: cannot parse heat.nml: …, then error stop 3.
  • A file with nx = 0config: grid must be positive, got 0 x 8, then error 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

  1. 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?
  2. 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.
  3. The checking parameter 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 alpha a thousand times any real material's. Decide whether it should be a hard error stop or a warning to error_unit that lets the run proceed, and justify the choice.
  • Option B. Refactor load_config to return a stat argument instead of calling error stop itself, 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 checking parameter (or the C preprocessor), keeping the cheap catastrophic ones always on — the same develop-thoroughly, ship-lean discipline as -fcheck.