Search code examples
cmakemodulefortran

How to prevent CMake usage of an outdated fortran module


I'm using CMake to build a moderate-sized Fortran project. I noticed that sometimes when I refactor modules I ran into weird compilation errors that are gone after clean rebuild in a fresh build directory.

I've managed to reproduce the problem in the following minimal example. Consider a project with two libraries and a single executable

├── build
├── CMakeLists.txt
├── liba
│   ├── CMakeLists.txt
│   ├── first.f90
│   └── second.f90
├── libb
│   ├── CMakeLists.txt
│   └── third.f90
└── main.f90

The main.f90 is simply

program test
  use first, only: x
  use second, only: y
  use third, only: z
  implicit none
  print *, x, y, z
end program

and the root CMakeLists.txt is

project(test)
enable_language(Fortran)

add_subdirectory(liba)
add_subdirectory(libb)

add_executable(main main.f90)
target_link_libraries(main a b)

Each module just exports a single variable

! first.f90
module first
  integer, parameter :: x = 1
end module first
! second.f90
module second
  integer, parameter :: y = 2
end module second
! third.f90
module third
  integer, parameter :: z = 3
end module third

The libraries are described similarly as

# liba/CMakeLists.txt
add_library(a
    first.f90
    second.f90
)
target_include_directories(a PUBLIC ${CMAKE_CURRENT_BINARY_DIR})
# libb/CMakeLists.txt
add_library(b
    third.f90
)

target_include_directories(b PUBLIC ${CMAKE_CURRENT_BINARY_DIR})

Building and running I get 1 2 3, as expected.

Now the problem. If I move the second.f90 to libb and change y = 2 to y = 42 I still have 1 2 3 as the output, even if I do full rebuild using make -C build clean all. This is because liba output directory contains second.mod with outdated parameter value y = 2 and it is used instead of the correct module in the libb build directory. The Ninja generator behaves just the same.

I greatly appreciate any suggestions how to fix my build scripts to avoid this kind of errors.

Just in case you want to try it yourself, here is the example on GitHub:

$ git clone https://github.com/uranix/cmake-fortran-modules
$ cd cmake-fortran-modules
$ git checkout a90ddfc
$ mkdir build
$ cmake -B build
$ make -C build
$ ./build/main 
           1           2           3
$ git checkout 736b738
$ make -C build
$ ./build/main 
           1           2           3
$ make -C build clean all
$ ./build/main 
           1           2           3
$ mkdir build2
$ cmake -B build2
$ make -C build2
$ ./build2/main 
           1          42           3

PS. I've heard that C++20 also has modules and I wonder whether the problem can be reproduced with C++ instead of Fortran. I'll check and expand the question later


Solution

  • While there are some complications that specifically stem from module files (or any generated files), the underlying issue here can really be reproduced even with normal libraries.

    CMake already handles dependency tracking for module files, knows when to rebuild targets based on time stamps and only when the module file has actually changed. A lot of this happens as build time due to the nature of generated files (you can view a lot of happens with make VERBOSE=1).

    It also takes care of only cleaning the stuff that it has caused; if a target generates second.mod, a make clean will only remove second.mod.

    For all cmake can know, extra files might have been generated by some external tools or custom commands, so expecting it to figure out that there are orphaned mod files from an earlier different cmake configuration is just asking too much.

    When you check out the different branch, which has a completely different setup, the first thing that happens, even if you start off with a clean, is that cmake is triggered and rebuilds all the dependencies and such to match the new setup, orphaning any files generated by the previous build. If you decided to rename liba to libc, you'd see the orphaned liba directory left over.


    As I see, you have a few options;

    1. You can make sure to clean before you switch branches (or start the major refactoring).
    $ git clone https://github.com/uranix/cmake-fortran-modules
    $ cd cmake-fortran-modules
    $ git checkout a90ddfc
    $ mkdir build
    $ cmake -B build
    $ make -C build
    $ ./build/main 
               1           2           3
    $ make -C build clean  # make sure to do this before refactoring!
    $ git checkout 736b738
    $ make -C build
    $ ./build/main         # and it will work
               1          42           3
    

    You could possibly even add this as a local git hook (but i don't think there exists a pre-checkout hook)

    1. You can accept that such major refactorings require a clean slate.
    2. Might be possible to enrich your clean command by combining a glob for *.mod files and setting set_property(DIRECTORY PROPERTY ADDITIONAL_MAKE_CLEAN_FILES list_of_mod_files). Still would require a manual clean, so not my favorite option. You could of course also just run a manual rm build/lib*/*.mod
    3. You can use a shared mod directory by setting Fortran_MODULE_DIRECTORY for each target. Modify each lib{a,b}/CMakeLists accordingly
    set_target_properties(a PROPERTIES Fortran_MODULE_DIRECTORY ${CMAKE_BINARY_DIR}/mod)
    target_include_directories(a PUBLIC ${CMAKE_BINARY_DIR}/mod)
    
    set_target_include_directories(b PROPERTIES Fortran_MODULE_DIRECTORY ${CMAKE_BINARY_DIR}/mod)
    target_include_directories(b PUBLIC ${CMAKE_BINARY_DIR}/mod)
    

    assuming that is a good fit for your submodules. Note that this still wouldn't be detect against yet another orphaned fourth.mod which might accidentally been left over by a library you removed completely, but still was in USE by the main program.