diff --git a/Prototypes/ViewShell/README.md b/Prototypes/ViewShell/README.md index 09ded632ba1b..4df6a06e46eb 100644 --- a/Prototypes/ViewShell/README.md +++ b/Prototypes/ViewShell/README.md @@ -53,15 +53,19 @@ Prototypes/ViewShell/inject.sh --live ~/.cache/cmux-view-shell/live Sources/View ``` `inject.sh` compiles the module with the Debug build's settings, links only the edited files into a -signed dylib and moves it into the live folder. The shell (`LiveCode.swift`) loads it, interposes +signed dylib and moves it into the live folder. It also links, unnamed, every file the running +Debug build never compiled (a new file) and every file changed since that build (it prints +`also X.swift` for each), so each patch holds all of the new code: a new view in a new file, +used from an edited one, applies, and patching again never leaves an earlier patch's copy behind. The shell (`LiveCode.swift`) loads it, interposes every Swift function the patch defines onto the executable's own (and onto an earlier patch's copy, so the same file can be patched again and again), and redraws. The patch can be compiled on another Mac at the same commit and copied into the folder; it is the same file. -What applies live: any change inside a function or computed property, a view's body included. A +What applies live: any change inside a function or computed property, a view's body included, +and new files with new types and functions. A view whose `body` is `AnyView(content)` (`PreviewBadge`) may also change what it builds, its modifiers and their types. What needs a rebuild: a new or changed stored property, a changed -function signature, a new type used from another file, `main.swift` and `LiveCode.swift` +function signature, a new type, conformance or enum case in an existing file, `main.swift` and `LiveCode.swift` themselves, resources and the package manifest. A patch whose internal or public types change size (a stored property added or removed, or retyped to another size) is refused before anything is repointed: the shell compares each type's size and stride, or a class's instance size, with diff --git a/Prototypes/ViewShell/inject.sh b/Prototypes/ViewShell/inject.sh index c88277ee2955..21597d6089a9 100755 --- a/Prototypes/ViewShell/inject.sh +++ b/Prototypes/ViewShell/inject.sh @@ -7,6 +7,12 @@ # # Prototypes/ViewShell/inject.sh --live ~/.cache/cmux-view-shell/live Sources/ViewShell/Badge.swift # +# The patch also takes, with no need to name them, every Swift file of the module the running +# build did not compile (a new file: its types and functions live only in the patch, and the +# edited files that use them link against them there) and every file changed since that build +# (an earlier patch's file, so each patch holds all of the new code and none of it goes stale). +# The build is the package's Debug build (`.build/debug`); CMUX_VIEW_SHELL_BUILD names another. +# # CMUX_LIVE_CODE_SIGN_IDENTITY signs with that identity (a Developer ID for a signed host); # the default is ad hoc, which an unsigned Debug shell accepts. set -euo pipefail @@ -35,9 +41,28 @@ started=$(python3 -c 'import time; print(f"{time.time():.3f}")') all=("$sources"/*.swift) (cd "$work" && xcrun swiftc -c "${all[@]}" -module-name ViewShell -target "$target" \ -Onone -g -enable-testing -DDEBUG -DSWIFT_PACKAGE -swift-version 6) +# What the running build compiled (an object per file in `ViewShell.build`) and when: a file with +# no object is new, and one newer than the executable changed since. +build="${CMUX_VIEW_SHELL_BUILD:-$package/.build/debug}" +compiled="$build/ViewShell.build" +executable="$build/cmux-view-shell" +names=() +for file in "${files[@]}"; do names+=("$(basename "$file" .swift)"); done +if [[ -d "$compiled" && -f "$executable" ]]; then + for file in "${all[@]}"; do + name="$(basename "$file" .swift)" + why="" + if [[ ! -f "$compiled/$name.swift.o" ]]; then why="new since the build" + elif [[ "$file" -nt "$executable" ]]; then why="changed since the build"; fi + [[ -n "$why" && " ${names[*]} " != *" $name "* ]] || continue + names+=("$name") + echo "inject.sh: also $name.swift ($why)" + done +else + echo "inject.sh: no Debug build at $build; the patch holds only the files named" >&2 +fi objects=() -for file in "${files[@]}"; do - name="$(basename "$file" .swift)" +for name in "${names[@]}"; do [[ "$name" == main || "$name" == LiveCode ]] && { echo "inject.sh: $name.swift holds the shell's own loop; rebuild for it" >&2; exit 65; } objects+=("$work/$name.o") done diff --git a/docs/cmux-next/hot-reload.md b/docs/cmux-next/hot-reload.md index ab39f7fb81f9..f6b07b1d5d75 100644 --- a/docs/cmux-next/hot-reload.md +++ b/docs/cmux-next/hot-reload.md @@ -87,10 +87,10 @@ reads the PR's diff with git and takes the cheapest path. A web-only PR goes str `web-overlay.sh follow`, so the running app shows it in seconds with no build. A PR that changes only CI, tests or docs is reported as nothing to try. A PR that changes the daemon, Swift, a resource or a manifest needs a new binary: the script says why (a stored property, a changed -signature, a new file, and so on), and with `--build` it requests the fleet dev build tagged `pr-`. +signature, a new type in an existing file, and so on), and with `--build` it requests the fleet dev build tagged `pr-`. The rules are in `scripts/cmux-next/live_apply.py`. Over the last 3,895 NEXT-era PRs, about half of the ones that touch the app changed only web code or nothing in the app, so they need no new binary. -A Swift change confined to function bodies could be injected live once the app takes live code +A Swift change confined to function bodies and new files could be injected live once the app takes live code (the view shell does today); until then it takes the dev build. ### No dev server: built bundles diff --git a/scripts/cmux-next/live_apply.py b/scripts/cmux-next/live_apply.py index a9c687aa943c..8bbc29644c0d 100755 --- a/scripts/cmux-next/live_apply.py +++ b/scripts/cmux-next/live_apply.py @@ -8,9 +8,11 @@ no-app docs, CI, tests, scripts, website and backend only: nothing in the app changes web-live plus the app's web bundles (webviews/): hot-swapped into the running app daemon plus Rust (cmux-tui, acpmux): a daemon or CLI restart with the change's binary - swift-live plus app Swift changed only inside function bodies and computed properties + swift-live plus app Swift changed only inside function bodies and computed properties, or new + Swift files (the patch carries them: Prototypes/ViewShell/inject.sh) rebuild a new binary: a stored property, a changed signature, a new type, conformance or - enum case, a new Swift file, a resource, manifest, plist or entitlement + enum case in an existing file, a new entry point (`@main`), a resource, manifest, + plist or entitlement ios-only iOS only: the Mac app does not change The rules are regular expressions over the diff lines, checked by hand on 20 merged PRs (19 right): @@ -37,6 +39,7 @@ STORED = re.compile(r"^(\s*)(@\w+(\([^)]*\))?\s+)*((public|private|fileprivate|internal|package|open|final|static|nonisolated|lazy|weak|unowned|override)(\([^)]*\))?\s+)*(let|var)\s+\w+[^{]*$") FUNC = re.compile(r"^\s*(@\w+(\([^)]*\))?\s+)*((public|private|fileprivate|internal|package|open|final|static|class|override|mutating|nonisolated|required|convenience)(\([^)]*\))?\s+)*(func\s+\w+|init[?!]?\s*[(<]|subscript\s*[(<])") TYPE_DECL = re.compile(r"^\s*(@\w+(\([^)]*\))?\s+)*((public|private|fileprivate|internal|package|open|final|indirect)\s+)*(struct|class|enum|actor|protocol|extension)\s+\w+") +ENTRY = re.compile(r"^\s*@main\b") CASE = re.compile(r"^ {4}(\t)?case\s+\w+|^\tcase\s+\w+") # Heaviest first: a change takes the verdict of its heaviest file. @@ -47,7 +50,10 @@ def swift_reasons(lines, added_file): """Why a Swift file's change needs a rebuild; empty when it only changes function bodies. `lines` are the diff's ("+" or "-", text) pairs.""" if added_file: - return ["new file"] + # Its types and functions are new symbols that live only in the patch, with the edited + # files that use them; the running build has nothing of them to replace. An entry point + # is the one thing a patch cannot add. + return ["new entry point"] if any(sign == "+" and ENTRY.match(text) for sign, text in lines) else [] reasons = set() removed, added = [], [] for sign, line in lines: @@ -55,7 +61,9 @@ def swift_reasons(lines, added_file): if not body.strip() or COMMENT.match(body): continue indent = len(body) - len(body.lstrip(" ")) - if TYPE_DECL.match(body): + if ENTRY.match(body) and sign == "+": + reasons.add("new entry point") + elif TYPE_DECL.match(body): reasons.add("type or conformance") elif STORED.match(body) and indent <= 4 and not body.endswith("{"): reasons.add("stored property") # member level (one indent) or a global @@ -96,6 +104,8 @@ def note(kind, path, why=None): elif path.endswith(".swift"): # A deleted file's code stays in the running binary, and nothing calls it any more. why = [] if status == "D" else swift_reasons(diffs.get(path, []), status == "A") + if status == "A" and os.path.basename(path) == "main.swift": + why = ["new entry point"] for reason in why: note("rebuild", path, reason) if not why: diff --git a/scripts/cmux-next/tests/test_live_apply.py b/scripts/cmux-next/tests/test_live_apply.py index d8ca716a5d5d..e525e945c2bc 100644 --- a/scripts/cmux-next/tests/test_live_apply.py +++ b/scripts/cmux-next/tests/test_live_apply.py @@ -53,13 +53,21 @@ def test_a_changed_signature_needs_a_rebuild_and_a_new_function_does_not(self): added = {VIEW: [("+", " func helper() -> Int {"), ("+", " 1"), ("+", " }")]} self.assertEqual(self.verdict([("M", VIEW)], added), "swift-live") - def test_new_files_types_enum_cases_and_resources_need_a_rebuild(self): - self.assertEqual(self.verdict([("A", VIEW)]), "rebuild") + def test_new_types_enum_cases_and_resources_need_a_rebuild(self): self.assertEqual(self.verdict([("M", VIEW)], {VIEW: [("+", "struct Chip: View {")]}), "rebuild") self.assertEqual(self.verdict([("M", VIEW)], {VIEW: [("+", " case pinned")]}), "rebuild") self.assertEqual(self.verdict([("M", "Resources/Localizable.xcstrings")]), "rebuild") self.assertEqual(self.verdict([("M", "Packages/macOS/CmuxNext/Package.swift")]), "rebuild") + def test_a_new_swift_file_is_live_unless_it_is_an_entry_point(self): + new = "Packages/macOS/CmuxNext/Sources/CmuxNextUI/Chip.swift" + diffs = {new: [("+", "struct Chip: View {"), ("+", " let title: String"), ("+", " var body: some View { Text(title) }"), ("+", "}")], + VIEW: [("+", " Chip(title: name)")]} + self.assertEqual(self.verdict([("A", new), ("M", VIEW)], diffs), "swift-live") + self.assertEqual(self.verdict([("A", new)], {new: [("+", "@main"), ("+", "struct App {}")]}), "rebuild") + self.assertEqual(self.verdict([("A", "Packages/macOS/CmuxNext/Sources/CmuxNextApp/main.swift")]), "rebuild") + self.assertEqual(self.verdict([("M", VIEW)], {VIEW: [("+", "@main")]}), "rebuild") + def test_a_deleted_swift_file_is_live_and_ios_alone_leaves_the_mac_app(self): self.assertEqual(self.verdict([("D", VIEW)]), "swift-live") self.assertEqual(self.verdict([("M", "cmux-next-ios/App/Root.swift")]), "ios-only") diff --git a/scripts/cmux-next/try-pr.sh b/scripts/cmux-next/try-pr.sh index 60f3c1fb18a2..b22c7813ef3e 100755 --- a/scripts/cmux-next/try-pr.sh +++ b/scripts/cmux-next/try-pr.sh @@ -66,7 +66,7 @@ case "$verdict" in daemon|swift-live|rebuild) case "$verdict" in daemon) echo "the daemon or CLI changes: it needs the PR's binary, so a dev build" ;; - swift-live) echo "Swift changes only inside function bodies: injectable once the app takes live code, a dev build until then" ;; + swift-live) echo "Swift changes only inside function bodies, or new Swift files: injectable once the app takes live code, a dev build until then" ;; rebuild) echo "the app needs a new binary: a dev build" ;; esac if [[ -z "$build" ]]; then