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
44 changes: 39 additions & 5 deletions contrib/vibes/QA.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ checked.

## Automated suites

- 106 console tests and 17 installer tests (`python3 -m unittest discover -s
- 108 console tests and 17 installer tests (`python3 -m unittest discover -s
contrib/vibes/tests`), all passing, including the tests that need a real
node. See `tests/README.md` for what each group covers.
- Browser checks of the landing page and demo in Chrome via Playwright: the
Expand Down Expand Up @@ -189,6 +189,37 @@ own rules; tools from Homebrew on Linux may not run inside the sandbox; a
shared request that needs invisible joiners (some scripts, some emoji) is
refused and has to be retyped.

## The release, tried as a new install would use it

The release workflow ran for the first time on `v31.99-7`. All three platforms
(macOS on Apple silicon, Linux on x86_64 and on ARM64) compiled the node,
passed Bitcoin Core's unit tests and passed this project's tests against the
node just built; on the Mac that includes the tests that probe the sandbox
from inside. It built the three changed nodes, checked that each disagrees
with the unmodified node in the documented direction, and published the
release only after every file was attached.

The published files were then used on an Apple silicon Mac the way a new
install uses them: the installer's ready-made node step, and the console's
download of the node and of the three changed nodes. That found two faults
that no test had caught, because both need a real release to show:

- The console did not recognise the release's node as its unmodified
reference. A release build names itself by its tag, and the console only
understood a commit. With no reference, every ready-made experiment asked
for a compile first. The console now understands both, and the installer
leaves a note of the commit beside the node.
- Installing a ready-made node over an earlier one overwrote the program in
place, and macOS on Apple silicon silently refuses to run a program changed
that way. Programs are now put in place as new files.

`v31.99-7` was marked a pre-release so that it is not offered as the latest.
With both faults fixed, the same check against the same published files
passed: the checksum and commit matched, the node was adopted as the
reference, and the doubled-reward, halved-reward and earlier-halving nodes
gave "loosened", "tightened" and "tightened", with the rule-breaking-miner
scene refusing all thirty blocks.

## Not checked

- **A live AI session.** The agent's command line was accepted by Claude Code
Expand All @@ -205,10 +236,13 @@ refused and has to be retyped.
routes are closed. They are not an attempt by a specialist to find others.
- **Docker.** CI builds the image and checks that the console answers. The
review-first behaviour inside it is covered by a unit test only.
- **The Mac app**, the release workflow, signing and notarisation. None can run
outside GitHub Actions or without credentials. That includes the new step
that builds and ships the three changed nodes, and the console's download of
them, which is covered by tests against a local folder only.
- **The Mac app**, signing and notarisation. The release workflow builds the
app, but nobody has opened the one it built, and signing and notarisation
need credentials the project does not have yet.
- **A complete install from nothing.** The installer's ready-made node step
and the console's downloads were run against a real release. The rest of
the installer (installing build tools, fetching the source) was not run on
a clean machine, and the ready-made files were tried on a Mac only.
- **Whether any of this teaches.** No learner has used it. The lessons have
not been reviewed by a Bitcoin Core contributor or an educator. Both are
things only people can do; `doc/vibes-study.md` and the "Reviewing the
Expand Down
64 changes: 51 additions & 13 deletions contrib/vibes/bitcoin-vibes
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,18 @@ SETTINGS = (
)


def install_program(source, target):
"""Copy a program into place as a new file, never over the old one.

macOS on Apple silicon remembers the signature of a program it has run.
One that is overwritten in place no longer matches what was remembered
and is killed the moment it is started, with no message."""
target = Path(target)
fresh = target.with_name(f".{target.name}.{os.getpid()}.new")
shutil.copy2(source, fresh)
os.replace(fresh, target)


def run(cmd, cwd=None, timeout=60, env=None):
"""Run a command, return (returncode, stdout, stderr)."""
try:
Expand Down Expand Up @@ -577,7 +589,7 @@ class Console:
for name in ("bitcoind", "bitcoin-cli"):
binary = self.bin_path(name)
if binary:
shutil.copy2(binary, bootstrap / name)
install_program(binary, bootstrap / name)
self.build_state = {"path": str(bootstrap), "sha": None, "verified": False}
self._write_json(self.state_dir / "build.json", self.build_state)

Expand Down Expand Up @@ -1645,26 +1657,43 @@ class Console:
if current.get("tree") == tree and (self.reference_dir / "bitcoind").exists():
return True
self.reference_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(Path(release_dir) / "bitcoind", self.reference_dir / "bitcoind")
install_program(Path(release_dir) / "bitcoind", self.reference_dir / "bitcoind")
self._write_json(self.reference_dir / "reference.json",
{"tree": tree, "sha": sha, "time": time.time()})
if job:
job.emit(t="log", line="Kept this build as the unmodified reference node")
return True

def _adopt_installed_reference(self):
"""On first start, accept the installer's build as the reference if the
program itself reports that it was built from unmodified source."""
"""On first start, accept the installed node program as the reference
if it was built from unmodified source.

Which source it was built from is read from the note a ready-made
node is installed with (the commit its release recorded, already
checked against this source when it was installed), or else from what
the program says about itself: a commit, or the release tag it was
built at. Either is believed only while the build folder has never
been shared with a build of changed source."""
if self.reference_state()["ready"] or self._build_folder_was_shared():
return
bitcoind = self.bin_path("bitcoind")
if not bitcoind:
return
code, out, _ = self.ask_node_program(bitcoind, "-version")
found = re.search(r"version v[0-9][^\s-]*-([0-9a-f]{7,40})(-dirty)?\b", out) if code == 0 else None
if not found or found.group(2):
return
code, sha, _ = self.git("rev-parse", "--verify", "--quiet", found.group(1) + "^{commit}")
note, name = bitcoind.parent / "COMMIT", ""
try:
if note.is_file() and not note.is_symlink():
name = note.read_text()[:100].strip()
except OSError:
name = ""
if not re.fullmatch(r"[0-9a-f]{40}", name):
code, out, _ = self.ask_node_program(bitcoind, "-version")
found = re.search(r"version (v[0-9][0-9A-Za-z._-]*)", out) if code == 0 else None
if not found or found.group(1).endswith("-dirty"):
return
# "v31.99.0-1a2b3c4d5e" names a commit; "v31.99-7" is a release tag.
commit = re.search(r"-([0-9a-f]{7,40})$", found.group(1))
name = commit.group(1) if commit else "refs/tags/" + found.group(1)
code, sha, _ = self.git("rev-parse", "--verify", "--quiet", name + "^{commit}")
if code == 0:
self._capture_reference(sha.strip(), bitcoind.parent)

Expand Down Expand Up @@ -2437,6 +2466,12 @@ class Console:
raise RuntimeError("Your build folder is not an ordinary folder (it is a link or a file), so "
"nothing is compiled there. If you did not put it there, a program built "
"from changed source did. Remove it, then build again.")
# The note a ready-made node came with says nothing about what is
# compiled here from now on.
try:
(build_dir / "bin" / "COMMIT").unlink()
except OSError:
pass
code = self._stream(job, ["cmake", "-B", "build", "-DBUILD_TESTS=ON", "-DBUILD_BENCH=OFF",
"-DBUILD_FUZZ_BINARY=OFF", "-DBUILD_GUI=OFF"],
write=[build_dir], on_line=on_line)
Expand Down Expand Up @@ -2495,7 +2530,7 @@ class Console:
release = self.state_dir / "releases" / (sha + "-" + job.id)
release.mkdir(parents=True, exist_ok=True)
for name in ("bitcoind", "bitcoin-cli"):
shutil.copy2(self.bin_path(name), release / name)
install_program(self.bin_path(name), release / name)
if identical and self._build_folder_was_shared():
# Unmodified source, but compiled where builds of changed source
# have been able to write. The unmodified node is never taken
Expand All @@ -2508,7 +2543,7 @@ class Console:
job.meta["test_status"] = "cancelled"
return None
if self.reference_state()["ready"]:
shutil.copy2(self.reference_dir / "bitcoind", release / "bitcoind")
install_program(self.reference_dir / "bitcoind", release / "bitcoind")
job.emit(t="log", line="Source is unmodified: the node kept for it is the reference node itself")

item = lessons.curated(job.meta.get("curated", ""))
Expand Down Expand Up @@ -2833,7 +2868,7 @@ class Console:
def _keep_variant(self, item, release_dir, source):
folder = self.variant_dir(item["id"])
folder.mkdir(parents=True, exist_ok=True)
shutil.copy2(Path(release_dir) / "bitcoind", folder / "bitcoind")
install_program(Path(release_dir) / "bitcoind", folder / "bitcoind")
self._write_json(folder / "variant.json",
{"tree": self.node_key(self.stock_rev()), "patch": self._patch_digest(item),
"source": source, "time": time.time()})
Expand Down Expand Up @@ -3194,8 +3229,11 @@ class Console:
dest = self.repo / "build" / "bin"
dest.mkdir(parents=True, exist_ok=True)
for name in ("bitcoind", "bitcoin-cli"):
shutil.copy2(src / name, dest / name)
install_program(src / name, dest / name)
(dest / name).chmod(0o755)
# A note of the commit this program was built from, for adopting
# it as the unmodified reference node.
(dest / "COMMIT").write_text(built_from + "\n")
if self.ask_node_program(dest / "bitcoind", "-version")[0] != 0:
job.emit(t="log", line="It would not run on this machine; compiling instead")
return False
Expand Down
11 changes: 8 additions & 3 deletions contrib/vibes/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -389,16 +389,21 @@ try_prebuilt() {
rm -rf "$tmp"; return 1
fi
mkdir -p "$DEST/build/bin"
cp "$dir/bitcoind" "$dir/bitcoin-cli" "$DEST/build/bin/" || { rm -rf "$tmp"; return 1; }
# Remove any earlier programs first, so the new ones are new files: macOS
# on Apple silicon kills a program that was overwritten in place after it
# had been run. COMMIT goes along as a note of what the program was built
# from; the console reads it when it adopts this node as its reference.
rm -f "$DEST/build/bin/bitcoind" "$DEST/build/bin/bitcoin-cli" "$DEST/build/bin/COMMIT"
cp "$dir/bitcoind" "$dir/bitcoin-cli" "$dir/COMMIT" "$DEST/build/bin/" || { rm -rf "$tmp"; return 1; }
chmod +x "$DEST/build/bin/bitcoind" "$DEST/build/bin/bitcoin-cli"
if ! "$DEST/build/bin/bitcoind" -version >/dev/null 2>&1; then
note "the prebuilt node would not run here — building instead"
rm -rf "$tmp" "$DEST/build/bin"; return 1
fi
rm -rf "$tmp"
note "using a prebuilt node built from this exact source"
note "your first experiment compiles the node from source: that wait has been"
note "postponed, not avoided"
note "the ready-made experiments need no compiling. A request in your own"
note "words compiles the node from source: that wait is postponed, not avoided"
return 0
}

Expand Down
61 changes: 61 additions & 0 deletions contrib/vibes/tests/test_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,67 @@ def compile_apart(job):
self.assertEqual(record['compare']['verdict'], 'identical')
self.assertEqual((Path(record['path']) / 'bitcoind').read_text(), 'compiled apart')

def test_programs_are_put_in_place_as_new_files(self):
"""Overwriting a program that has been run gets it killed on Apple
silicon. Replacing it gives the new program a file of its own."""
folder = self.repo / 'build/bin'
folder.mkdir(parents=True)
(folder / 'new').write_text('new program')
target = folder / 'bitcoind'
target.write_text('old program')
before = target.stat().st_ino
held = open(target) # as if the old program were still running
self.addCleanup(held.close)
vibes.install_program(folder / 'new', target)
self.assertEqual(target.read_text(), 'new program')
self.assertNotEqual(target.stat().st_ino, before)
self.assertEqual(held.read(), 'old program')
self.assertEqual(sorted(p.name for p in folder.iterdir()), ['bitcoind', 'new'])
self.programs('from a build')
self.console._capture_reference(self.console.stock_rev(), folder)
first = (self.console.reference_dir / 'bitcoind').stat().st_ino
(self.console.reference_dir / 'reference.json').unlink()
self.console._capture_reference(self.console.stock_rev(), folder)
self.assertNotEqual((self.console.reference_dir / 'bitcoind').stat().st_ino, first)

def test_a_release_node_is_adopted_as_the_reference(self):
"""A node installed from a release says "v31.99-7", a tag, where a
local build says "v31.99.0-<commit>". Both, and the note a ready-made
node is installed with, must be recognised: otherwise a new install
has no reference and every ready-made experiment asks for a compile."""
head = self.git('rev-parse', 'HEAD')
self.git('tag', 'v31.99-7')
says = {'version': ''}

def program(bitcoind, flag, timeout=30):
return 0, f"Bitcoin Vibes daemon version {says['version']} bitcoind\nCopyright\n", ''

def adopted(version, note=None):
shutil.rmtree(self.console.reference_dir, ignore_errors=True)
self.programs('from the release')
if note:
(self.repo / 'build/bin/COMMIT').write_text(note + '\n')
elif (self.repo / 'build/bin/COMMIT').exists():
(self.repo / 'build/bin/COMMIT').unlink()
says['version'] = version
with patch.object(self.console, 'ask_node_program', program):
self.console._adopt_installed_reference()
return self.reference() == 'from the release'
self.assertTrue(adopted('v31.99-7')) # a release tag
self.assertTrue(adopted('v31.99.0-' + head[:12])) # a commit
self.assertTrue(adopted('nonsense', note=head)) # the installer's note
self.assertFalse(adopted('v31.99-8')) # a tag this source does not have
self.assertFalse(adopted('v31.99.0-' + head[:12] + '-dirty'))
self.assertFalse(adopted('v31.99-7', note='0' * 40)) # a note naming no commit here
# Compiling here ends what the note says.
(self.repo / 'build/bin/COMMIT').write_text(head + '\n')
with self.compiling():
self.console._compile(vibes.Job('build'))
self.assertFalse((self.repo / 'build/bin/COMMIT').exists())
# And nothing in a shared folder is believed.
self.console._note_build_folder_shared('abc')
self.assertFalse(adopted('v31.99-7', note=head))

def test_current_source_is_kept_as_the_reference_before_a_first_proposal(self):
self.programs()

Expand Down
3 changes: 3 additions & 0 deletions contrib/vibes/tests/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@ def test_matching_archive_is_used(self):
self.assertEqual(code, 0, out)
self.assertTrue((self.dest / 'build/bin/bitcoind').is_file())
self.assertIn('not an independent signature', out)
# The commit it was built from goes along, so the console can adopt it
# as the unmodified reference node.
self.assertEqual((self.dest / 'build/bin/COMMIT').read_text().strip(), self.commit)

def test_tampered_archive_is_refused(self):
self.publish(checksum='0' * 64)
Expand Down
Loading