diff --git a/platformio/builder/main.py b/platformio/builder/main.py index 880ca155ac..086bc34777 100644 --- a/platformio/builder/main.py +++ b/platformio/builder/main.py @@ -26,6 +26,7 @@ from SCons.Script import Default # pylint: disable=import-error from SCons.Script import DefaultEnvironment # pylint: disable=import-error from SCons.Script import Import # pylint: disable=import-error +from SCons.Script import SetOption # pylint: disable=import-error from SCons.Script import Variables # pylint: disable=import-error from platformio import app, fs @@ -35,6 +36,13 @@ AllowSubstExceptions(NameError) +# SCons normally reuses stored content signatures for files older than its +# max-drift window when their size and modification time are unchanged. A VCS +# checkout can replace a source file while preserving both values, which makes +# the previous object look current. Always calculate dependency signatures from +# the actual contents so incremental builds cannot mix source states. +SetOption("max_drift", -1) + # append CLI arguments to build environment clivars = Variables(None) clivars.AddVariables( diff --git a/tests/commands/test_run.py b/tests/commands/test_run.py index 9250b7a6a0..64bfbc4ee2 100644 --- a/tests/commands/test_run.py +++ b/tests/commands/test_run.py @@ -12,11 +12,60 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os +import subprocess +import time from pathlib import Path from platformio.run.cli import cli as cmd_run +def test_rebuilds_source_when_content_changes_without_metadata_change( + clirunner, validate_cliresult, tmp_path: Path +): + project_dir = tmp_path / "project" + src_dir = project_dir / "src" + src_dir.mkdir(parents=True) + (project_dir / "platformio.ini").write_text(""" +[env:native] +platform = native +""") + + source_path = src_dir / "main.c" + source_path.write_text("int main(void) { return 0; }\n") + old_timestamp = time.time() - (3 * 24 * 60 * 60) + os.utime(source_path, (old_timestamp, old_timestamp)) + original_stat = source_path.stat() + + result = clirunner.invoke(cmd_run, ["--project-dir", str(project_dir)]) + validate_cliresult(result) + + # Reproduce a Git checkout that restores different content with the same + # file size and modification time. Timestamp-based SCons deciders otherwise + # risk reusing the object produced from the previous source state. + source_path.write_text("int main(void) { return 1; }\n") + os.utime( + source_path, + ns=(original_stat.st_atime_ns, original_stat.st_mtime_ns), + ) + + result = clirunner.invoke(cmd_run, ["--project-dir", str(project_dir)]) + validate_cliresult(result) + + object_path = project_dir / ".pio" / "build" / "native" / "src" / "main.o" + object_mtime_ns = object_path.stat().st_mtime_ns + + program_name = "program.exe" if os.name == "nt" else "program" + program_path = project_dir / ".pio" / "build" / "native" / program_name + assert subprocess.run([program_path], check=False).returncode == 1 + + # Hashing the source again must not turn an unchanged build into a rebuild. + time.sleep(0.01) + result = clirunner.invoke(cmd_run, ["--project-dir", str(project_dir)]) + validate_cliresult(result) + assert object_path.stat().st_mtime_ns == object_mtime_ns + + def test_generic_build(clirunner, validate_cliresult, tmpdir): build_flags = [ ("-D TEST_INT=13", "-DTEST_INT=13"),