Repository: mesonbuild/meson

Issue:
Builtin options not forwarded correctly to subprojects
**Describe the bug**
The simplest reproducer is:

```
diff --git a/test cases/common/281 subproj options/subprojects/sub/meson.build b/test cases/common/281 subproj options/subprojects/sub/meson.build
index 9e3bceacf..b1fb9558a 100644
--- a/test cases/common/281 subproj options/subprojects/sub/meson.build	
+++ b/test cases/common/281 subproj options/subprojects/sub/meson.build	
@@ -1,2 +1,3 @@
-project('subproject')
+project('subproject', 'c')
 assert(get_option('bar') == true)
+assert(get_option('b_lto') == true)
diff --git a/test cases/common/281 subproj options/test.json b/test cases/common/281 subproj options/test.json
new file mode 100644
index 000000000..fcdf4ddd8
--- /dev/null
+++ b/test cases/common/281 subproj options/test.json	
@@ -0,0 +1,7 @@
+{
+  "matrix": {
+    "options": {
+      "b_lto":      [{ "val": "true" }]
+    }
+  }
+}
```

That is, a superproject is configured with `-Db_lto=true` but the subproject sees `b_lto` as false. This breaks QEMU's CFI builds (where the clang flags for CFI are added with add_global_arguments) but is a regression in general.

**system parameters**
* Is this a [cross build](https://mesonbuild.com/Cross-compilation.html) or just a plain native build (for the same computer)? no
* what operating system (e.g. MacOS Catalina, Windows 10, CentOS 8.0, Ubuntu 18.04, etc.) Fedora 42
* what Python version are you using e.g. 3.13.2
* what `meson --version` 1.7.99 commit 02792fa7787
* what `ninja --version` if it's a Ninja build N/A

Discussion:
@bonzini: Some more notes... If (in addition to the above diff) `test cases/common/281 subproj options/meson.build` is changed to specify `project('pkg_opt_test', 'c')`, it also doesn't work, but I have the following patch (though I'm not sure if I know what I'm doing):

```
diff --git a/mesonbuild/coredata.py b/mesonbuild/coredata.py
index 36120ff7e..562c3d3cb 100644
--- a/mesonbuild/coredata.py
+++ b/mesonbuild/coredata.py
@@ -598,6 +598,8 @@ class CoreData:
     def process_compiler_options(self, lang: str, comp: Compiler, env: Environment, subproject: str) -> None:
         self.add_compiler_options(comp.get_options(), lang, comp.for_machine, env, subproject)
 
+        # Separate bug: does not take into account pending_options?
+        print('---', self.optstore.pending_options)
         for key in comp.base_options:
             if subproject:
                 skey = key.evolve(subproject=subproject)
@@ -605,17 +607,15 @@ class CoreData:
                 skey = key
             if skey not in self.optstore:
                 self.optstore.add_system_option(skey, copy.deepcopy(options.COMPILER_BASE_OPTIONS[key]))
-                if skey in env.options:
-                    self.optstore.set_option(skey, env.options[skey])
-                elif subproject and key in env.options:
-                    self.optstore.set_option(skey, env.options[key])
-                # FIXME
-                #if subproject and not self.optstore.has_option(key):
-                #    self.optstore[key] = copy.deepcopy(self.optstore[skey])
-            elif skey in env.options:
+
+            if skey in env.options:
                 self.optstore.set_option(skey, env.options[skey])
             elif subproject and key in env.options:
+                # ??? is this needed at all ???
                 self.optstore.set_option(skey, env.options[key])
+            elif subproject and key in self.optstore.options:
+                self.optstore.set_option(skey, self.optstore.options[key].value)
+
         self.emit_base_options_warnings()
 
     def emit_base_options_warnings(self) -> None:
```

In the testcase at the top of the issue, `OptionStore.pending_options` is also involved because the superproject does not enable a language that processes `b_lto`.

I am not sure if these are two completely separate bugs, or just one on top of the other.@bonzini: The remaining issue is that coredata should IMO not be using env.options at all. `project()` passes its contents are passed to the option store via 

```
self.coredata.optstore.initialize_from_top_level_project_call(self.project_default_options,
                                                              self.user_defined_options.cmd_line_options,
                                                              self.environment.options)
```

and after this call that should be it. Therefore add_compiler_options()/process_compiler_options() should be entirely based on the OptionStore's pending_options.@dcbaker: I kinda suspected this was a problem and really wanted more time to continue reworking the option refactor stuff before we made the 1.8 branch, but I've been busy with the work I'm actually paid for.@Totto16: > I kinda suspected this was a problem and really wanted more time to continue reworking the option refactor stuff before we made the 1.8 branch, but I've been busy with the work I'm actually paid for.

For that reason, it's only rc1 and not 1.8.0, so no worries there 😄 

release candidates are there for that exact reason, find such bugs

Solution patch:
diff --git a/.github/workflows/unusedargs_missingreturn.yml b/.github/workflows/unusedargs_missingreturn.yml
index 72f39b511..d6f1246d1 100644
--- a/.github/workflows/unusedargs_missingreturn.yml
+++ b/.github/workflows/unusedargs_missingreturn.yml
@@ -42,7 +42,7 @@ permissions:
 jobs:
 
   linux:
-    runs-on: ubuntu-20.04
+    runs-on: ubuntu-22.04
     steps:
     - uses: actions/checkout@v4
     - uses: actions/setup-python@v5
diff --git a/mesonbuild/backend/backends.py b/mesonbuild/backend/backends.py
index 20f7907a1..3dfa2fba6 100644
--- a/mesonbuild/backend/backends.py
+++ b/mesonbuild/backend/backends.py
@@ -1196,25 +1196,31 @@ class Backend:
         links to and return them so they can be used in unit
         tests.
         """
-        result: T.Set[str] = set()
         prospectives: T.Set[build.BuildTargetTypes] = set()
+        internal_deps: T.Set[str] = set()
+        external_deps: T.Set[str] = set()
+
         if isinstance(target, build.BuildTarget):
             prospectives.update(target.get_all_link_deps())
-            # External deps
-            result.update(self.extract_dll_paths(target))
 
         for bdep in extra_bdeps:
             prospectives.add(bdep)
             if isinstance(bdep, build.BuildTarget):
                 prospectives.update(bdep.get_all_link_deps())
+
         # Internal deps
         for ld in prospectives:
             dirseg = os.path.join(self.environment.get_build_dir(), self.get_target_dir(ld))
-            result.add(dirseg)
-        if (isinstance(target, build.BuildTarget) and
-                not self.environment.machines.matches_build_machine(target.for_machine)):
-            result.update(self.get_mingw_extra_paths(target))
-        return list(result)
+            internal_deps.add(dirseg)
+
+        if isinstance(target, build.BuildTarget):
+            # External deps
+            external_deps.update(self.extract_dll_paths(target))
+
+            if not self.environment.machines.matches_build_machine(target.for_machine):
+                external_deps.update(self.get_mingw_extra_paths(target))
+
+        return list(internal_deps) + list(external_deps)
 
     def write_benchmark_file(self, datafile: T.BinaryIO) -> None:
         self.write_test_serialisation(self.build.get_benchmarks(), datafile)
diff --git a/mesonbuild/coredata.py b/mesonbuild/coredata.py
index 36120ff7e..a94fdbdac 100644
--- a/mesonbuild/coredata.py
+++ b/mesonbuild/coredata.py
@@ -568,14 +568,6 @@ class CoreData:
     def add_compiler_options(self, c_options: MutableKeyedOptionDictType, lang: str, for_machine: MachineChoice,
                              env: Environment, subproject: str) -> None:
         for k, o in c_options.items():
-            value = env.options.get(k)
-            if value is not None:
-                o.set_value(value)
-                if not subproject:
-                    # FIXME, add augment
-                    #self.optstore[k] = o  # override compiler option on reconfigure
-                    pass
-
             comp_key = OptionKey(f'{k.name}', None, for_machine)
             if lang == 'objc' and k.name == 'c_std':
                 # For objective C, always fall back to c_std.
@@ -605,17 +597,7 @@ class CoreData:
                 skey = key
             if skey not in self.optstore:
                 self.optstore.add_system_option(skey, copy.deepcopy(options.COMPILER_BASE_OPTIONS[key]))
-                if skey in env.options:
-                    self.optstore.set_option(skey, env.options[skey])
-                elif subproject and key in env.options:
-                    self.optstore.set_option(skey, env.options[key])
-                # FIXME
-                #if subproject and not self.optstore.has_option(key):
-                #    self.optstore[key] = copy.deepcopy(self.optstore[skey])
-            elif skey in env.options:
-                self.optstore.set_option(skey, env.options[skey])
-            elif subproject and key in env.options:
-                self.optstore.set_option(skey, env.options[key])
+
         self.emit_base_options_warnings()
 
     def emit_base_options_warnings(self) -> None:
diff --git a/mesonbuild/interpreter/interpreter.py b/mesonbuild/interpreter/interpreter.py
index d3bf86df3..185bb07ee 100644
--- a/mesonbuild/interpreter/interpreter.py
+++ b/mesonbuild/interpreter/interpreter.py
@@ -1059,7 +1059,7 @@ class Interpreter(InterpreterBase, HoldableObject):
     @typed_pos_args('get_option', str)
     @noKwargs
     def func_get_option(self, node: mparser.BaseNode, args: T.Tuple[str],
-                        kwargs: kwtypes.FuncGetOption) -> T.Union[options.UserOption, 'TYPE_var']:
+                        kwargs: TYPE_kwargs) -> T.Union[options.UserOption, 'TYPE_var']:
         optname = args[0]
 
         if ':' in optname:
@@ -1069,6 +1069,9 @@ class Interpreter(InterpreterBase, HoldableObject):
         if optname_regex.search(optname.split('.', maxsplit=1)[-1]) is not None:
             raise InterpreterException(f'Invalid option name {optname!r}')
 
+        # Will be None only if the value comes from the default
+        value_object: T.Optional[options.AnyOptionType]
+
         try:
             optkey = options.OptionKey(optname, self.subproject)
             value_object, value = self.coredata.optstore.get_value_object_and_value_for(optkey)
@@ -1076,7 +1079,14 @@ class Interpreter(InterpreterBase, HoldableObject):
             if self.coredata.optstore.is_base_option(optkey):
                 # Due to backwards compatibility return the default
                 # option for base options instead of erroring out.
-                return self.coredata.optstore.get_default_for_b_option(optkey)
+                #
+                # TODO: This will have issues if we expect to return a user FeatureOption
+                #       Of course, there's a bit of a layering violation here in
+                #       that we return a UserFeatureOption, but otherwise the held value
+                #       We probably need a lower level feature thing, or an enum
+                #       instead of strings
+                value = self.coredata.optstore.get_default_for_b_option(optkey)
+                value_object = None
             else:
                 if self.subproject:
                     raise MesonException(f'Option {optname} does not exist for subproject {self.subproject}.')
@@ -1087,7 +1097,7 @@ class Interpreter(InterpreterBase, HoldableObject):
             ocopy.value = value
             return ocopy
         elif optname == 'b_sanitize':
-            assert isinstance(value_object, options.UserStringArrayOption)
+            assert value_object is None or isinstance(value_object, options.UserStringArrayOption)
             # To ensure backwards compatibility this always returns a string.
             # We may eventually want to introduce a new "format" kwarg that
             # allows the user to modify this behaviour, but for now this is
@@ -1095,13 +1105,10 @@ class Interpreter(InterpreterBase, HoldableObject):
             if not value:
                 return 'none'
             return ','.join(sorted(value))
-        elif isinstance(value_object, options.UserOption):
-            if isinstance(value_object.value, str):
-                return P_OBJ.OptionString(value, f'{{{optname}}}')
-            return value
-        ocopy = copy.copy(value_object)
-        ocopy.value = value
-        return ocopy
+
+        if isinstance(value, str):
+            return P_OBJ.OptionString(value, f'{{{optname}}}')
+        return value
 
     @typed_pos_args('configuration_data', optargs=[dict])
     @noKwargs
diff --git a/mesonbuild/options.py b/mesonbuild/options.py
index 5fc16f5e1..62413b1a7 100644
--- a/mesonbuild/options.py
+++ b/mesonbuild/options.py
@@ -890,16 +890,23 @@ class OptionStore:
             raise MesonException(f'Internal error: non-module option has a period in its name {key.name}.')
         self.add_system_option_internal(key, valobj)
 
-    def add_system_option_internal(self, key: T.Union[OptionKey, str], valobj: AnyOptionType) -> None:
-        key = self.ensure_and_validate_key(key)
+    def add_system_option_internal(self, key: OptionKey, valobj: AnyOptionType) -> None:
         assert isinstance(valobj, UserOption)
         if not isinstance(valobj.name, str):
             assert isinstance(valobj.name, str)
-        if key not in self.options:
-            self.options[key] = valobj
-            pval = self.pending_options.pop(key, None)
-            if pval is not None:
-                self.set_option(key, pval)
+        if key in self.options:
+            return
+
+        self.options[key] = valobj
+        pval = self.pending_options.pop(key, None)
+        if key.subproject:
+            proj_key = key.evolve(subproject=None)
+            self.add_system_option_internal(proj_key, valobj)
+            if pval is None:
+                pval = self.options[proj_key].value
+
+        if pval is not None:
+            self.set_option(key, pval)
 
     def add_compiler_option(self, language: str, key: T.Union[OptionKey, str], valobj: AnyOptionType) -> None:
         key = self.ensure_and_validate_key(key)
@@ -910,14 +917,14 @@ class OptionStore:
     def add_project_option(self, key: T.Union[OptionKey, str], valobj: AnyOptionType) -> None:
         key = self.ensure_and_validate_key(key)
         assert key.subproject is not None
-        pval = self.pending_options.pop(key, None)
         if key in self.options:
             raise MesonException(f'Internal error: tried to add a project option {key} that already exists.')
-        else:
-            self.options[key] = valobj
-            self.project_options.add(key)
-            if pval is not None:
-                self.set_option(key, pval)
+
+        self.options[key] = valobj
+        self.project_options.add(key)
+        pval = self.pending_options.pop(key, None)
+        if pval is not None:
+            self.set_option(key, pval)
 
     def add_module_option(self, modulename: str, key: T.Union[OptionKey, str], valobj: AnyOptionType) -> None:
         key = self.ensure_and_validate_key(key)


Test patch:
diff --git a/unittests/optiontests.py b/unittests/optiontests.py
index 3b3ffc98e..5ed601fed 100644
--- a/unittests/optiontests.py
+++ b/unittests/optiontests.py
@@ -35,6 +35,20 @@ class OptionTests(unittest.TestCase):
         optstore.initialize_from_top_level_project_call({OptionKey('someoption'): new_value}, {}, {})
         self.assertEqual(optstore.get_value_for(k), new_value)
 
+    def test_subproject_system_option(self):
+        """Test that subproject system options get their default value from the global
+           option (e.g. "sub:b_lto" can be initialized from "b_lto")."""
+        optstore = OptionStore(False)
+        name = 'someoption'
+        default_value = 'somevalue'
+        new_value = 'new_value'
+        k = OptionKey(name)
+        subk = k.evolve(subproject='sub')
+        optstore.initialize_from_top_level_project_call({}, {}, {OptionKey(name): new_value})
+        vo = UserStringOption(k.name, 'An option of some sort', default_value)
+        optstore.add_system_option(subk, vo)
+        self.assertEqual(optstore.get_value_for(subk), new_value)
+
     def test_parsing(self):
         with self.subTest('subproject'):
             s1 = OptionKey.from_string('sub:optname')