Dependency not being respected - executable not being relinked when necessary

Consider the following reduced and seemingly silly example. We require four files:

.
├── CMakeLists.txt
├── ex1.cxx
├── ex2.cxx
└── gen_linker.py

CMakeLists.txt:

cmake_minimum_required(VERSION 3.24)
project(linking)

add_library(ex1-compiled OBJECT ex1.cxx)
add_library(ex2-compiled OBJECT ex2.cxx)

add_custom_command(
    OUTPUT
        linker.ld
    COMMAND
        python3 ${CMAKE_CURRENT_SOURCE_DIR}/gen_linker.py --dir ${CMAKE_CURRENT_SOURCE_DIR} > linker.ld
    DEPENDS
        ex1-compiled $<TARGET_OBJECTS:ex1-compiled>
        ex2-compiled $<TARGET_OBJECTS:ex2-compiled>
)
add_custom_target(linker.ld-target DEPENDS linker.ld)

add_executable(ex1 $<TARGET_OBJECTS:ex1-compiled> linker.ld)
add_dependencies(ex1 linker.ld-target)
target_link_options(ex1 PRIVATE "-g" "LINKER:@linker.ld")

add_executable(ex2 $<TARGET_OBJECTS:ex2-compiled> linker.ld)
add_dependencies(ex2 linker.ld-target)
target_link_options(ex2 PRIVATE "-g" "LINKER:@linker.ld")

The python gen_linker.py simply counts the number of lines in ex1.cxx and ex2.cxx and outputs them:

import argparse

parser = argparse.ArgumentParser()
parser.add_argument("--dir")
args = parser.parse_args()

def num_lines(filename):
    return open(f"{args.dir}/{filename}").read().count('\n')

lines1 = num_lines("ex1.cxx")
lines2 = num_lines("ex2.cxx")
print(f"--defsym=lines1={lines1} --defsym=lines2={lines2}")

And ex1.cxx and ex2.cxx are both initially:

#include <cstdio>
#include <cstdint>

extern int lines1;
extern int lines2;

int main(int, char** argv) {
    printf("%s] lines1=%ld lines2=%ld\n", argv[0], (intptr_t)&lines1, (intptr_t)&lines2);
}

If we try to compile this (same behavior with both Ninja and Unix Makefiles):

$ mkdir build && cd build
$ cmake -G Ninja ..
$ ninja ex1 ex2
$ ./ex1
./ex1] lines1=9 lines2=9
$ ./ex2
./ex2] lines1=9 lines2=9

Now, if we change ex2.cxx to just add another printf:

#include <cstdio>
#include <cstdint>

extern int lines1;
extern int lines2;

int main(int, char** argv) {
    printf("%s] lines1=%ld lines2=%ld\n", argv[0], (intptr_t)&lines1, (intptr_t)&lines2);
+   printf("ex2 rocks\n");
}

Then:

$ ninja ex1 ex2
$ ./ex1
./ex1] lines1=9 lines2=9
$ ./ex2
./ex2] lines1=9 lines2=10
ex2 rocks

ex1 should print that lines2=10 though, same as ex2, since ex2.cxx was updated which caused linker.ld to be updated updated (it does correctly read --defsym=lines2=10), but it doesn’t. ex1 was not re-linked.

What needs to change in order to get this to work? That is: how do I convinced CMake to actually relink both ex1 and ex2 when linker.ld is regenerated?

In case it’s useful, this is the intended dependency diagram here:

In this case, mutating ex2.cxx does modify ex2.o, which leads to modifying linker.ld, which modifies ex2. But ex1 is not updated.

I do not understand you intention to work in this way?

But with small modification to make it portable, it works for me:

Claus-iMac:build clausklein$ ninja -nv -d explain
ninja: no work to do.
Claus-iMac:build clausklein$ ninja -nv -d explain
ninja explain: output CMakeFiles/ex1-compiled.dir/ex1.cxx.o older than most recent input /Users/clausklein/Workspace/cmake/tmp/ex1.cxx (1694199831302477561 vs 1694199853806158000)
ninja explain: CMakeFiles/ex1-compiled.dir/ex1.cxx.o is dirty
ninja explain: ex1-compiled is dirty
ninja explain: CMakeFiles/ex1-compiled.dir/ex1.cxx.o is dirty
ninja explain: CMakeFiles/ex1-compiled.dir/ex1.cxx.o is dirty
ninja explain: /Users/clausklein/Workspace/cmake/tmp/build/linker.cxx is dirty
ninja explain: /Users/clausklein/Workspace/cmake/tmp/build/linker.cxx is dirty
ninja explain: CMakeFiles/ex1.dir/linker.cxx.o is dirty
ninja explain: ex1 is dirty
ninja explain: /Users/clausklein/Workspace/cmake/tmp/build/linker.cxx is dirty
ninja explain: /Users/clausklein/Workspace/cmake/tmp/build/linker.cxx is dirty
ninja explain: CMakeFiles/ex2.dir/linker.cxx.o is dirty
ninja explain: ex2 is dirty
[1/6] /usr/local/bin/g++-13   -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.3.sdk -MD -MT CMakeFiles/ex1-compiled.dir/ex1.cxx.o -MF CMakeFiles/ex1-compiled.dir/ex1.cxx.o.d -o CMakeFiles/ex1-compiled.dir/ex1.cxx.o -c /Users/clausklein/Workspace/cmake/tmp/ex1.cxx
[2/6] cd /Users/clausklein/Workspace/cmake/tmp/build && python3 /Users/clausklein/Workspace/cmake/tmp/gen_linker.py --dir /Users/clausklein/Workspace/cmake/tmp > linker.cxx
[3/6] /usr/local/bin/g++-13   -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.3.sdk -MD -MT CMakeFiles/ex1.dir/linker.cxx.o -MF CMakeFiles/ex1.dir/linker.cxx.o.d -o CMakeFiles/ex1.dir/linker.cxx.o -c /Users/clausklein/Workspace/cmake/tmp/build/linker.cxx
[4/6] /usr/local/bin/g++-13   -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.3.sdk -MD -MT CMakeFiles/ex2.dir/linker.cxx.o -MF CMakeFiles/ex2.dir/linker.cxx.o.d -o CMakeFiles/ex2.dir/linker.cxx.o -c /Users/clausklein/Workspace/cmake/tmp/build/linker.cxx
[5/6] : && /usr/local/bin/g++-13 -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.3.sdk -Wl,-search_paths_first -Wl,-headerpad_max_install_names  CMakeFiles/ex1-compiled.dir/ex1.cxx.o CMakeFiles/ex1.dir/linker.cxx.o -o ex1   && :
[6/6] : && /usr/local/bin/g++-13 -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.3.sdk -Wl,-search_paths_first -Wl,-headerpad_max_install_names  CMakeFiles/ex2-compiled.dir/ex2.cxx.o CMakeFiles/ex2.dir/linker.cxx.o -o ex2   && :
Claus-iMac:build clausklein$ 

CMakeLists.txt

cmake_minimum_required(VERSION 3.24...3.27)

project(linking LANGUAGES CXX)

add_library(ex1-compiled OBJECT ex1.cxx)
add_library(ex2-compiled OBJECT ex2.cxx)

add_custom_command(
  OUTPUT linker.cxx
  COMMAND python3 ${CMAKE_CURRENT_SOURCE_DIR}/gen_linker.py --dir ${CMAKE_CURRENT_SOURCE_DIR} >
          linker.cxx
  DEPENDS ex1-compiled $<TARGET_OBJECTS:ex1-compiled> ex2-compiled $<TARGET_OBJECTS:ex2-compiled>
)

add_executable(ex1 $<TARGET_OBJECTS:ex1-compiled> linker.cxx)
add_executable(ex2 $<TARGET_OBJECTS:ex2-compiled> linker.cxx)

gen_linker.py

#! /usr/bin/env python3

import argparse

parser = argparse.ArgumentParser()
parser.add_argument("--dir")
args = parser.parse_args()


def num_lines(filename):
    return open(f"{args.dir}/{filename}").read().count("\n")


lines1 = num_lines("ex1.cxx")
lines2 = num_lines("ex2.cxx")
print(f"int lines1={lines1};\nint lines2={lines2};\n")

There are some subtle things going on in your original example. They might or might not be the cause of the behavior you’re seeing, but it is easy enough to find out.

  • In add_custom_command(), if you list a relative path after OUTPUT, that is treated as relative to the current build directory (${CMAKE_CURRENT_BINARY_DIR}).
  • In your call to add_custom_target(), you list a relative path to linker.ld after DEPENDS. The CMake docs for add_custom_target() are silent on how that is interpreted. After a quick look at the CMake code, I think it is treated the same as for add_custom_command(), which has a detailed explanation. Based on the explanation there, the bare linker.ld should get treated as a path relative to ${CMAKE_CURRENT_BINARY_DIR}. It’s the last fallback when all other possibilities don’t apply.
  • In both calls to add_executable(), linker.ld is once again listed without any path. In such cases, the file must exist in either the source or build directory, but I don’t recall the order in which CMake checks. This behavior is not documented, as far as I can tell. Note that it will do this check at CMake generation time, not at build time.

Now, based on the above, the final result of all the dependencies should be well-defined, but I’ve long had suspicions that there may be some subtle bugs or unexpected behaviours lurking amongst all that. To remove all doubt, personally I don’t rely on those rules and always specify absolute paths when I expect a file to be in the build directory. It would be good if we could eliminate this from your example, if nothing else but to confirm that there isn’t something funky going on with all that. Here’s a modified version with paths expressed explicitly:

cmake_minimum_required(VERSION 3.24)
project(linking)

add_library(ex1-compiled OBJECT ex1.cxx)
add_library(ex2-compiled OBJECT ex2.cxx)

set(linker_ld_file ${CMAKE_CURRENT_BINARY_DIR}/linker.ld)

add_custom_command(
    OUTPUT
        ${linker_ld_file}
    COMMAND
        python3 ${CMAKE_CURRENT_SOURCE_DIR}/gen_linker.py --dir ${CMAKE_CURRENT_SOURCE_DIR} > ${linker_ld_file}
    DEPENDS
        ex1-compiled $<TARGET_OBJECTS:ex1-compiled>
        ex2-compiled $<TARGET_OBJECTS:ex2-compiled>
)
add_custom_target(linker.ld-target DEPENDS ${linker_ld_file})

add_executable(ex1 $<TARGET_OBJECTS:ex1-compiled> ${linker_ld_file})
add_dependencies(ex1 linker.ld-target)
target_link_options(ex1 PRIVATE "-g" "LINKER:@${linker_ld_file}")

add_executable(ex2 $<TARGET_OBJECTS:ex2-compiled> ${linker_ld_file})
add_dependencies(ex2 linker.ld-target)
target_link_options(ex2 PRIVATE "-g" "LINKER:@${linker_ld_file}")

There’s a few other things in your example that I wouldn’t do that way or that I think are unnecessary, but those are unlikely to be related to your problem, so I’ll refrain from commenting on those so we can keep discussion focused on your problem.

This is quite rude. I do not care about your opinion about the quality of the C++ code that exists in this question solely to be a minimal reproduced example of the build issue that I am trying to get resolved.

The comments you have added to this post are not even an attempt to answer my question. They have not been helpful.

Hi Craig! Thanks for the response.

I changed the CMakeLists.txt as you suggested - using absolute paths everywhere. In my real example, I was doing that already - this was a poor reproduction on my part. But doing so doesn’t resolve the issue. Even with absolute paths, ex1 isn’t being updated when a change to ex2.cxx triggers a change to linker.ld.

I did some digging into this example with Ninja, and from what I can tell, the crux of the problem is that the executable targets end up with an order-only dependency on the linker.ld-target custom target. I think that explains why the first build works (no targets are up to date yet), but the second build after modifying ex2.cpp doesn’t cause ex1 to be relinked. That’s a bit of a guess on my part, but I couldn’t see any else immediately obvious which may explain the behavior.

This is getting a bit beyond my understanding of the nitty gritty details of the way dependencies are defined. We may need input from @brad.king on this one. I would have expected your example to work, but maybe there’s some subtle aspect of this I’m overlooking.

You didn’t explain your task you want to solve with your prototype.

Q: do you really need a linker ld file?
Q: does it has something to do with c++?
Q: should your solution only work on Linux?
Q: do you really need to count the lines in your main?

My Example works on every build host with every c/c++ compiler, yours not!

I will try to explain my prototype:

You need a clear, simple dependency DAG.
If you really want to create a linker ld file, that depends on every source file in your project, then my example is also not right!

But if you only want to link all your mains, if the ld file was updated, it works fine.

Simply touch an empty source file that will be linked to your mains.

cmake_minimum_required(VERSION 3.24...3.27)

project(linking LANGUAGES CXX)

add_library(ex1-compiled OBJECT ex1.cxx)
add_library(ex2-compiled OBJECT ex2.cxx)

set(linker_ld_file ${CMAKE_CURRENT_BINARY_DIR}/linker.ld)

add_custom_command(
  OUTPUT linker_generated.cxx  ${linker_ld_file}
  COMMAND python3 ${CMAKE_CURRENT_SOURCE_DIR}/gen_linker.py --dir ${CMAKE_CURRENT_SOURCE_DIR} >
          linker_generated.cxx
  COMMAND touch ${linker_ld_file}
  DEPENDS ex1-compiled $<TARGET_OBJECTS:ex1-compiled> ex2-compiled $<TARGET_OBJECTS:ex2-compiled>
)

add_executable(ex1 $<TARGET_OBJECTS:ex1-compiled> linker_generated.cxx)
target_link_options(ex1 PRIVATE "-g" "LINKER:@${linker_ld_file}")

add_executable(ex2 $<TARGET_OBJECTS:ex2-compiled> linker_generated.cxx)
target_link_options(ex2 PRIVATE "-g" "LINKER:@${linker_ld_file}")
1 Like

Let’s focus on the problem raised in the original post. That post does highlight that the example given was a “reduced and seemingly silly example”. We should expect that what has been presented isn’t meant to be the best way to achieve something, only that it is somehow representative of a problem in a larger, more complex project.

@Barry_Revzin So that we can refocus discussions here, can you please confirm that the main problem you’re asking about is that you expected the gen_linker.py script to be re-executed and both ex1 and ex2 to be re-linked if either ex1.cxx or ex2.cxx is modified, but you’re not seeing that occur and you’d like to understand why?

Yes, exactly. Thank you.

Yeah, that’s right. I made a cute mermaid diagram of the dependencies that I posted up top. Basically when ex2.cxx is updated, ex2.o needs to get rebuilt (this happens), and then linker.ld needs to get regenerated (this happens), and then ex2 needs to get relinked with the new object file and the new linker file (this also happens) but ex1 doesn’t get relinked even though linker.ld was updated.

A colleague pointed me to LINK_DEPENDS and this is where it gets a little weirder. Here’s a slightly longer version of the CMakeLists.txt file. I added two options, ADD_DEP and SET_PROP. ADD_DEP=On SET_PROP=Off is what my original example was doing:

cmake_minimum_required(VERSION 3.24)
project(linking)

add_library(ex1-compiled OBJECT ex1.cxx)
add_library(ex2-compiled OBJECT ex2.cxx)

set(linker_ld_file ${CMAKE_CURRENT_BINARY_DIR}/linker.ld)

add_custom_command(
    OUTPUT
        ${linker_ld_file}
    COMMAND
        python3 ${CMAKE_CURRENT_SOURCE_DIR}/gen_linker.py --dir ${CMAKE_CURRENT_SOURCE_DIR} > ${linker_ld_file}
    DEPENDS
        ex1-compiled $<TARGET_OBJECTS:ex1-compiled>
        ex2-compiled $<TARGET_OBJECTS:ex2-compiled>
)
add_custom_target(linker.ld-target DEPENDS ${linker_ld_file})

add_executable(ex1 $<TARGET_OBJECTS:ex1-compiled> ${linker_ld_file})
target_link_options(ex1 PRIVATE "-g" "LINKER:@${linker_ld_file}")

add_executable(ex2 $<TARGET_OBJECTS:ex2-compiled> ${linker_ld_file})
target_link_options(ex2 PRIVATE "-g" "LINKER:@${linker_ld_file}")

option(SET_PROP OFF)
if(SET_PROP)
    message(STATUS "Calling set_target_properties")
    set_target_properties(ex1 PROPERTIES LINK_DEPENDS linker.ld-target)
    set_target_properties(ex2 PROPERTIES LINK_DEPENDS linker.ld-target)
endif()

option(ADD_DEP OFF)
if(ADD_DEP)
    message(STATUS "Calling add_dependencies")
    add_dependencies(ex1 linker.ld-target)
    add_dependencies(ex2 linker.ld-target)
endif()

Now, here is the behavior I see.

Ninja ADD_DEP=Off ADD_DEP=On
SET_PROP=Off doesn’t compile (won’t build linker.ld) :x: builds but doesn’t relink :x:
SET_PROP=On works! :heavy_check_mark: works! :heavy_check_mark:

That setting both options to Off (top left) causes the initial build to fail makes sense - there’s no expressed dependency on linker.ld so it isn’t generated.

Unix Makefiles ADD_DEP=Off ADD_DEP=On
SET_PROP=Off builds but doesn’t relink (???) :x: builds but doesn’t relink :x:
SET_PROP=On doesn’t build* :x: doesn’t build* :x:

I don’t understand this table. Setting both options to off still has the build succeed, despite not expressing the dependency on linker.ld anywhere. But if you add set_target_properties, that’s when I get this failure:

make[2]: *** No rule to make target 'linker.ld-target', needed by 'ex1'.  Stop.
make[1]: *** [CMakeFiles/Makefile2:171: CMakeFiles/ex1.dir/all] Error 2
make: *** [Makefile:91: all] Error 2

And that’s regardless if you also add add_dependencies() or not.

I suspect that’s due to the split view the Makefiles have of the whole build graph. FWIW, until we have a global graph for Makefiles as well (needed for reliable C++ modules support), I think it’d be OK to just say “use the Ninja generator” for now.

1 Like

Note that I was seeing problems even with the Ninja generator.