Skip to content

Commit 2620b8f

Browse files
committed
[ExecuTorch][WebGPU] Make WGSL generation rollback-safe
Pull Request resolved: #21448 **Make WGSL publication rollback-safe without changing generated runtime output** Generated headers and the registry were previously written incrementally, so later validation, interruption, or write failure could leave a partial generated tree. This change stages and validates the complete output set before sequential publication, rejects path/name/C++ symbol collisions, and restores replaced outputs on failure or interruption. The family model follows Vulkan's YAML/template generation. The WebGPU publisher adds staging, backup, rollback, and temporary-file cleanup; it does not claim filesystem-atomic multi-file replacement. Key changes: - `gen_wgsl_headers.py` — stage, validate, publish, restore on failure, and clean temporary outputs. - `test_wgsl_codegen.py` — cover collisions, orphan repair, interruption, rollback, and idempotence. All generated shader bytes and runtime registry entries remain unchanged. Co-authored-with: Claude Code. ghstack-source-id: 411961465 @exported-using-ghexport Differential Revision: [D113979658](https://our.internmc.facebook.com/intern/diff/D113979658/)
1 parent e1c76b4 commit 2620b8f

2 files changed

Lines changed: 582 additions & 56 deletions

File tree

backends/webgpu/scripts/gen_wgsl_headers.py

Lines changed: 214 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,14 @@
2626
import copy
2727
import hashlib
2828
import io
29+
import os
2930
import re
31+
import stat
3032
import sys
33+
import tempfile
3134
from itertools import product
3235
from pathlib import Path
33-
from typing import Any, Dict, List, NamedTuple, Optional, Set
36+
from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple
3437

3538
import yaml
3639
from yaml.constructor import ConstructorError
@@ -523,23 +526,51 @@ def registry_path() -> Path:
523526
return BACKEND_ROOT / "runtime/WebGPUShaderRegistry.cpp"
524527

525528

526-
def registry_entries() -> List[RegistryEntry]:
527-
"""Return one registry entry for every concrete generated shader."""
528-
entries = []
529+
def _registry_entry(header: Path) -> RegistryEntry:
530+
suffix = "_wgsl.h"
531+
if not header.name.endswith(suffix):
532+
raise ValueError(f"unexpected generated header name: {header.name}")
533+
name = header.name[: -len(suffix)]
534+
return RegistryEntry(
535+
name=name,
536+
include=header.relative_to(BACKEND_ROOT).as_posix(),
537+
symbol=symbol_base(name),
538+
)
539+
540+
541+
def _collect_header_outputs() -> Tuple[Dict[Path, str], List[RegistryEntry]]:
542+
"""Render every concrete header once and reject global collisions."""
543+
outputs: Dict[Path, str] = {}
544+
entries: List[RegistryEntry] = []
545+
registry_names: Set[str] = set()
546+
registry_symbols: Set[str] = set()
529547
for wgsl in discover():
530-
for header, _ in headers_for_shader(wgsl):
531-
suffix = "_wgsl.h"
532-
if not header.name.endswith(suffix):
533-
raise ValueError(f"unexpected generated header name: {header.name}")
534-
name = header.name[: -len(suffix)]
535-
entries.append(
536-
RegistryEntry(
537-
name=name,
538-
include=header.relative_to(BACKEND_ROOT).as_posix(),
539-
symbol=symbol_base(name),
548+
try:
549+
rendered_headers = list(headers_for_shader(wgsl))
550+
except Exception as error:
551+
raise ValueError(f"{wgsl.relative_to(BACKEND_ROOT)}: {error}") from error
552+
for header, rendered in rendered_headers:
553+
if header in outputs:
554+
raise ValueError(
555+
"duplicate generated header path: "
556+
f"{header.relative_to(BACKEND_ROOT)}"
540557
)
541-
)
542-
return sorted(entries)
558+
entry = _registry_entry(header)
559+
if entry.name in registry_names:
560+
raise ValueError(f"duplicate shader registry name: {entry.name}")
561+
if entry.symbol in registry_symbols:
562+
raise ValueError(f"duplicate shader registry symbol: {entry.symbol}")
563+
outputs[header] = rendered
564+
entries.append(entry)
565+
registry_names.add(entry.name)
566+
registry_symbols.add(entry.symbol)
567+
return outputs, sorted(entries)
568+
569+
570+
def registry_entries() -> List[RegistryEntry]:
571+
"""Return one registry entry for every concrete generated shader."""
572+
_, entries = _collect_header_outputs()
573+
return entries
543574

544575

545576
def render_registry(entries: List[RegistryEntry]) -> str:
@@ -630,7 +661,143 @@ def headers_for_shader(wgsl):
630661
yield header, render_header(stem, text, stem)
631662

632663

633-
def _report_drift(missing, stale) -> None:
664+
def collect_outputs() -> Tuple[Dict[Path, bytes], List[Path]]:
665+
"""Render the complete output tree and report unexpected old headers."""
666+
header_outputs, entries = _collect_header_outputs()
667+
outputs = {
668+
path: rendered.encode("utf-8") for path, rendered in header_outputs.items()
669+
}
670+
registry = registry_path()
671+
if registry in outputs:
672+
raise ValueError(f"duplicate generated output path: {registry}")
673+
outputs[registry] = render_registry(entries).encode("utf-8")
674+
675+
expected_headers = set(header_outputs)
676+
actual_headers = set((BACKEND_ROOT / "runtime/ops").glob("**/*_wgsl.h"))
677+
return outputs, sorted(actual_headers - expected_headers)
678+
679+
680+
class _OriginalOutput(NamedTuple):
681+
existed: bool
682+
contents: bytes
683+
mode: int
684+
685+
686+
def _stage_bytes(destination: Path, contents: bytes, mode: int) -> Path:
687+
"""Write one same-directory candidate without changing its destination."""
688+
fd, name = tempfile.mkstemp(
689+
prefix=f".{destination.name}.wgsl-gen-",
690+
suffix=".tmp",
691+
dir=destination.parent,
692+
)
693+
temporary = Path(name)
694+
try:
695+
with os.fdopen(fd, "wb") as output:
696+
output.write(contents)
697+
temporary.chmod(mode)
698+
except BaseException:
699+
try:
700+
temporary.unlink(missing_ok=True)
701+
except OSError:
702+
pass
703+
raise
704+
return temporary
705+
706+
707+
def _cleanup_temporaries(temporaries) -> List[str]:
708+
errors = []
709+
for temporary in temporaries:
710+
try:
711+
temporary.unlink(missing_ok=True)
712+
except OSError as error:
713+
errors.append(f"cannot remove temporary {temporary}: {error}")
714+
return errors
715+
716+
717+
def _stage_outputs(
718+
outputs: Dict[Path, bytes], changed: List[Path]
719+
) -> Tuple[Dict[Path, _OriginalOutput], Dict[Path, Path], List[str]]:
720+
originals: Dict[Path, _OriginalOutput] = {}
721+
staged: Dict[Path, Path] = {}
722+
try:
723+
for destination in sorted(changed):
724+
if destination.exists():
725+
original = _OriginalOutput(
726+
existed=True,
727+
contents=destination.read_bytes(),
728+
mode=stat.S_IMODE(destination.stat().st_mode),
729+
)
730+
else:
731+
original = _OriginalOutput(False, b"", 0o644)
732+
originals[destination] = original
733+
staged[destination] = _stage_bytes(
734+
destination, outputs[destination], original.mode
735+
)
736+
except BaseException as error:
737+
cleanup_errors = _cleanup_temporaries(staged.values())
738+
if isinstance(error, OSError):
739+
errors = [f"cannot stage generated output: {error}"] + cleanup_errors
740+
return originals, staged, errors
741+
raise
742+
return originals, staged, []
743+
744+
745+
def _rollback_outputs(
746+
originals: Dict[Path, _OriginalOutput],
747+
replaced: List[Path],
748+
staged: Dict[Path, Path],
749+
) -> List[str]:
750+
errors = []
751+
for destination in reversed(replaced):
752+
original = originals[destination]
753+
restore_temporary: Optional[Path] = None
754+
try:
755+
if original.existed:
756+
restore_temporary = _stage_bytes(
757+
destination, original.contents, original.mode
758+
)
759+
os.replace(restore_temporary, destination)
760+
else:
761+
destination.unlink(missing_ok=True)
762+
except OSError as error:
763+
errors.append(f"cannot roll back {destination}: {error}")
764+
finally:
765+
if restore_temporary is not None:
766+
errors.extend(_cleanup_temporaries([restore_temporary]))
767+
errors.extend(_cleanup_temporaries(staged.values()))
768+
return errors
769+
770+
771+
def _publish_outputs(outputs: Dict[Path, bytes], changed: List[Path]) -> List[str]:
772+
"""Stage and publish changed outputs, rolling back reported failures."""
773+
originals, staged, stage_errors = _stage_outputs(outputs, changed)
774+
if stage_errors:
775+
return stage_errors
776+
777+
replaced: List[Path] = []
778+
try:
779+
for destination in sorted(changed):
780+
try:
781+
os.replace(staged[destination], destination)
782+
except OSError:
783+
raise
784+
except BaseException:
785+
replaced.append(destination)
786+
raise
787+
else:
788+
replaced.append(destination)
789+
except OSError as commit_error:
790+
return [f"cannot publish generated output: {commit_error}"] + _rollback_outputs(
791+
originals, replaced, staged
792+
)
793+
except BaseException:
794+
_rollback_outputs(originals, replaced, staged)
795+
raise
796+
797+
return _cleanup_temporaries(staged.values())
798+
799+
800+
def _report_drift(missing, stale, orphans) -> None:
634801
"""Print the --check report for missing/stale committed headers."""
635802
if missing:
636803
print("Missing embedded WGSL headers (run scripts/gen_wgsl_headers.py):")
@@ -640,16 +807,10 @@ def _report_drift(missing, stale) -> None:
640807
print("Stale embedded WGSL headers (run scripts/gen_wgsl_headers.py):")
641808
for h in stale:
642809
print(f" {h.relative_to(BACKEND_ROOT)}")
643-
644-
645-
def _sync_generated_output(output, want, check, missing, stale) -> None:
646-
"""Write one generated file, or record its --check drift."""
647-
if output.exists() and output.read_text() == want:
648-
return
649-
if check:
650-
(missing if not output.exists() else stale).append(output)
651-
else:
652-
output.write_text(want)
810+
if orphans:
811+
print("Orphan embedded WGSL headers (remove or restore their sources):")
812+
for h in orphans:
813+
print(f" {h.relative_to(BACKEND_ROOT)}")
653814

654815

655816
def main(argv=None) -> int:
@@ -661,38 +822,35 @@ def main(argv=None) -> int:
661822
)
662823
args = parser.parse_args(argv)
663824

664-
stale = []
665-
missing = []
666-
errors = []
667-
for wgsl in discover():
668-
try:
669-
rendered = list(headers_for_shader(wgsl))
670-
# A malformed spec raises yaml.YAMLError (incl. UniqueKeyLoader's
671-
# ConstructorError) / ValueError / KeyError from parse_template_spec, and
672-
# a malformed template raises AssertionError from preprocess; catch them
673-
# all so a bad shader is a clean --check report, not a traceback.
674-
except (ValueError, KeyError, AssertionError, yaml.YAMLError) as e:
675-
errors.append(f"{wgsl.relative_to(BACKEND_ROOT)}: {e}")
676-
continue
677-
for header, want in rendered:
678-
# Full-content compare (not just the sha) catches generator-logic drift too.
679-
_sync_generated_output(header, want, args.check, missing, stale)
825+
try:
826+
outputs, orphans = collect_outputs()
827+
missing = []
828+
stale = []
829+
for output, want in sorted(outputs.items()):
830+
if not output.exists():
831+
missing.append(output)
832+
elif output.read_bytes() != want:
833+
stale.append(output)
834+
except Exception as error:
835+
print("Cannot generate WGSL outputs:")
836+
print(f" {error}")
837+
return 1
680838

681-
if not errors:
682-
try:
683-
registry = render_registry(registry_entries())
684-
output = registry_path()
685-
_sync_generated_output(output, registry, args.check, missing, stale)
686-
except ValueError as e:
687-
errors.append(f"shader registry: {e}")
839+
if orphans:
840+
_report_drift([], [], orphans)
841+
return 1
842+
843+
if args.check:
844+
if stale or missing:
845+
_report_drift(missing, stale, [])
846+
return 1
847+
return 0
688848

849+
errors = _publish_outputs(outputs, missing + stale)
689850
if errors:
690-
print("Cannot generate header (malformed shader):")
691-
for e in errors:
692-
print(f" {e}")
693-
return 1
694-
if args.check and (stale or missing):
695-
_report_drift(missing, stale)
851+
print("Cannot publish WGSL outputs:")
852+
for error in errors:
853+
print(f" {error}")
696854
return 1
697855
return 0
698856

0 commit comments

Comments
 (0)