Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions Prototypes/ViewShell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 27 additions & 2 deletions Prototypes/ViewShell/inject.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/cmux-next/hot-reload.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<N>`.
signature, a new type in an existing file, and so on), and with `--build` it requests the fleet dev build tagged `pr-<N>`.
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
Expand Down
18 changes: 14 additions & 4 deletions scripts/cmux-next/live_apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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.
Expand All @@ -47,15 +50,20 @@ 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:
body = line.rstrip()
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
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 10 additions & 2 deletions scripts/cmux-next/tests/test_live_apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion scripts/cmux-next/try-pr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading