diff --git a/openpilot/common/prebuilt_manifest.py b/openpilot/common/prebuilt_manifest.py
new file mode 100644
index 0000000000..a95aff3fe7
--- /dev/null
+++ b/openpilot/common/prebuilt_manifest.py
@@ -0,0 +1,98 @@
+import hashlib
+import json
+import os
+from pathlib import Path
+import stat
+import subprocess
+import sys
+
+
+RECEIPT = 'prebuilt.json'
+RUNTIME_ROOTS = ('openpilot/system/ui/', 'openpilot/selfdrive/ui/', 'openpilot/starpilot/ui/',
+ 'openpilot/starpilot/software/', 'openpilot/starpilot/galaxy/')
+
+
+def protected(path):
+ if path in ('prebuilt', RECEIPT):
+ return False
+ if path.endswith('.py') and path.startswith(RUNTIME_ROOTS):
+ return False
+ if path.startswith('openpilot/starpilot/galaxy/') and path.endswith(('.html', '.css', '.js', '.svg', '.png', '.jpg', '.woff2')):
+ return False
+ return True
+
+
+def inventory_digest(entries):
+ inventory = sorted((path, mode, blob) for path, mode, blob in entries if protected(path))
+ return hashlib.sha256(json.dumps(inventory, separators=(',', ':'), ensure_ascii=True).encode()).hexdigest()
+
+
+def revision_digest(git, revision):
+ entries = []
+ for record in git('ls-tree', '-rz', '--full-tree', revision).split('\0'):
+ if record:
+ metadata, path = record.split('\t', 1)
+ mode, kind, blob = metadata.split()
+ if kind != 'blob':
+ raise ValueError('Prebuilt inventory contains non-file source')
+ entries.append((path, mode, blob))
+ return inventory_digest(entries)
+
+
+def valid_receipt(git, revision):
+ if git('ls-tree', revision, '--', RECEIPT).strip().split()[:1] != ['100644']:
+ return False
+ try:
+ receipt = json.loads(git('show', f'{revision}:{RECEIPT}'))
+ return (type(receipt) is dict and type(receipt.get('schema_version')) is int and receipt['schema_version'] == 1 and
+ receipt.get('protected_inventory_sha256') == revision_digest(git, revision))
+ except (ValueError, TypeError, KeyError):
+ return False
+
+
+def write_receipt(repo):
+ repo = Path(repo).resolve()
+ def git(*args, input=None):
+ return subprocess.check_output(['git', '-c', 'core.hooksPath=/dev/null', '-C', str(repo), *args],
+ input=input, timeout=30).decode()
+ entries = []
+ regular = []
+ for record in git('ls-files', '--stage', '-z').split('\0'):
+ if not record:
+ continue
+ metadata, path = record.split('\t', 1)
+ indexed_mode, _, stage = metadata.split()
+ if stage != '0' or indexed_mode == '160000':
+ raise ValueError('Prebuilt source has unresolved or nested Git entries')
+ if not protected(path):
+ continue
+ file = repo / path
+ info = file.lstat()
+ if stat.S_ISLNK(info.st_mode):
+ mode = '120000'
+ blob = git('hash-object', '--stdin', input=os.fsencode(os.readlink(file))).strip()
+ elif stat.S_ISREG(info.st_mode):
+ mode = '100755' if info.st_mode & 0o111 else '100644'
+ regular.append((path, mode))
+ continue
+ else:
+ raise ValueError('Prebuilt source contains a non-regular file')
+ entries.append((path, mode, blob))
+ for offset in range(0, len(regular), 256):
+ batch = regular[offset:offset + 256]
+ blobs = git('hash-object', '--no-filters', '--', *(path for path, _ in batch)).splitlines()
+ if len(blobs) != len(batch):
+ raise ValueError('Prebuilt source hashing was incomplete')
+ entries.extend((path, mode, blob) for (path, mode), blob in zip(batch, blobs, strict=True))
+ receipt = dict(schema_version=1, protected_inventory_sha256=inventory_digest(entries))
+ temporary = repo / (RECEIPT + '.tmp')
+ try:
+ temporary.write_text(json.dumps(receipt, sort_keys=True) + '\n')
+ os.replace(temporary, repo / RECEIPT)
+ finally:
+ temporary.unlink(missing_ok=True)
+ return receipt
+
+
+if __name__ == '__main__':
+ write_receipt(sys.argv[1])
diff --git a/openpilot/starpilot/galaxy/software_operations.py b/openpilot/starpilot/galaxy/software_operations.py
index 307130fd57..64f01fe4da 100644
--- a/openpilot/starpilot/galaxy/software_operations.py
+++ b/openpilot/starpilot/galaxy/software_operations.py
@@ -96,8 +96,8 @@ class UpdaterProcess:
except (SoftwareOperationError, UpdaterControlError, OSError, RuntimeError, ValueError):
return False
- def send(self, action: str) -> None:
- if action not in ("check", "download"):
+ def send(self, action: str, *, branch=None, commit=None) -> None:
+ if action not in ("check", "download", "fast"):
raise SoftwareOperationError("Invalid updater command", 400)
with self.lock:
pid = self._manager_pid()
@@ -105,7 +105,10 @@ class UpdaterProcess:
if self._manager_pid() != pid or self._proc_identity(pid) != start:
raise SoftwareOperationError("Updater process changed", 503)
try:
- self.control.send(pid, start, action)
+ if action == "fast":
+ self.control.send(pid, start, action, branch=branch, commit=commit)
+ else:
+ self.control.send(pid, start, action)
except (UpdaterControlError, OSError, RuntimeError, ValueError):
raise SoftwareOperationError("Updater control is unavailable", 503) from None
@@ -158,6 +161,7 @@ class SoftwareOperations:
self.request: dict | None = None
self._baseline: tuple | None = None
self._started = 0.0
+ self._fast_commit = None
def _param(self, key: str, limit: int = 256) -> str | None:
try:
@@ -236,15 +240,25 @@ class SoftwareOperations:
if request is None or request["state"] != "pending" or request["action"] == "install":
return
state = status["updater"]["state"]
- if request["target"] is not None and status["updater"]["targetBranch"] != request["target"]:
+ if request["action"] != "fast" and request["target"] is not None and status["updater"]["targetBranch"] != request["target"]:
request.update(state="failed", error="Updater target changed")
elif state == "idle" and status["updater"]["failedCount"] is not None and self._baseline[2] is not None and \
status["updater"]["failedCount"] > self._baseline[2]:
- request.update(state="failed", error="Updater reported a failure")
+ error = "Updater reported a failure"
+ if request["action"] == "fast":
+ try:
+ error = self._param("LastUpdateException", 4096) or error
+ except SoftwareOperationError:
+ pass
+ request.update(state="failed", error=error)
elif state == "idle" and status["updater"]["failedCount"] == 0 and \
status["updater"]["lastSuccessAt"] is not None and \
status["updater"]["lastSuccessAt"] != self._baseline[0]:
- if request["action"] == "check" or status["updater"]["lastFetchAt"] != self._baseline[1]:
+ if request["action"] == "fast":
+ if status["updater"]["lastFetchAt"] != self._baseline[1]:
+ restarting = self._flag("DoReboot") is True or status["installed"]["commit"] != self._fast_commit
+ request.update(state="complete", error=None, outcome="restarting" if restarting else "up_to_date")
+ elif request["action"] == "check" or status["updater"]["lastFetchAt"] != self._baseline[1]:
request.update(state="complete", error=None)
elif self._ready(status, request["target"]):
request.update(state="complete", error=None)
@@ -284,7 +298,7 @@ class SoftwareOperations:
reason = "Updater is not running"
return {
"parked": parked, "availableBranches": branches, "selectedTarget": target,
- "canCheck": usable, "canSelect": usable and bool(branches),
+ "canCheck": usable, "canFastUpdate": usable and self._valid_branch(status["installed"]["branch"]), "canSelect": usable and bool(branches),
"canDownload": usable and selected, "canInstall": usable and selected and self._ready(status, target),
"reason": reason, "request": dict(self.request) if self.request is not None else None,
"automaticDownloads": automatic_downloads(self.params), "canConfigure": parked and not reboot,
@@ -334,7 +348,7 @@ class SoftwareOperations:
return self.snapshot()
def action(self, action: str, payload: object, *, authorized: Callable[[], bool]) -> dict:
- if type(payload) is not dict or type(action) is not str or action not in ("check", "download", "select", "install", "preferences"):
+ if type(payload) is not dict or type(action) is not str or action not in ("check", "download", "select", "install", "preferences", "fast"):
raise SoftwareOperationError("Invalid software action", 400)
if payload.get("action") != action or any(type(key) is not str for key in payload):
raise SoftwareOperationError("Invalid software action", 400)
@@ -357,10 +371,15 @@ class SoftwareOperations:
raise SoftwareOperationError("Vehicle is not safely parked", 409)
if view["reason"] is not None:
raise SoftwareOperationError(view["reason"], 503 if view["reason"] == "Updater is not running" else 409)
- if branch is not None and branch not in view["availableBranches"]:
+ if action != "fast" and branch is not None and branch not in view["availableBranches"]:
raise SoftwareOperationError("Branch is not in the current updater list", 409)
- if action != "check" and action != "select" and (branch != view["selectedTarget"] or not view["canDownload"]):
+ if action not in ("check", "select", "fast") and (branch != view["selectedTarget"] or not view["canDownload"]):
raise SoftwareOperationError("Selected updater target changed", 409)
+ if action == "fast":
+ installed = self.status.snapshot()["installed"]
+ identity = self.git_identity(self.installed)
+ if not view["canFastUpdate"] or branch != installed["branch"] or identity is None or identity != (branch, installed["commit"]):
+ raise SoftwareOperationError("Installed branch changed; refresh before updating", 409)
if action == "install" and not view["canInstall"]:
raise SoftwareOperationError("Finalized update does not match selected branch", 409)
if not authorized() or not self._parked() or self._flag("IsOnroad") is not False:
@@ -368,7 +387,8 @@ class SoftwareOperations:
current = self.status.snapshot()
if (current["updater"]["state"] != "idle" or self._flag("DisableUpdates") is not False or
self._flag("DoReboot") is not False or not self.process.available() or
- (branch is not None and branch not in self._branches())):
+ (action != "fast" and branch is not None and branch not in self._branches()) or
+ (action == "fast" and current["installed"]["branch"] != branch)):
raise SoftwareOperationError("Updater state changed", 409)
if action == "select":
self.params.put("UpdaterTargetBranch", branch, block=True)
@@ -387,7 +407,15 @@ class SoftwareOperations:
status["updater"]["failedCount"])
if not authorized() or not self._parked() or self._flag("IsOnroad") is not False:
raise SoftwareOperationError("Session or parked state changed", 403)
- self.process.send(action)
+ if action == "fast":
+ identity = self.git_identity(self.installed)
+ if identity is None or identity != (branch, status["installed"]["commit"]) or status["installed"]["branch"] != branch:
+ raise SoftwareOperationError("Installed branch changed; refresh before updating", 409)
+ self._fast_commit = status["installed"]["commit"]
+ if action == "fast":
+ self.process.send(action, branch=identity[0], commit=identity[1])
+ else:
+ self.process.send(action)
self.request = {"id": secrets.token_hex(8), "action": action,
"target": branch if branch is not None else status["updater"]["targetBranch"],
"state": "pending", "error": None}
diff --git a/openpilot/starpilot/galaxy/tests/test_software_operations.py b/openpilot/starpilot/galaxy/tests/test_software_operations.py
index 04ca6f8327..0032dc762a 100644
--- a/openpilot/starpilot/galaxy/tests/test_software_operations.py
+++ b/openpilot/starpilot/galaxy/tests/test_software_operations.py
@@ -16,14 +16,17 @@ class FakeProcess:
def __init__(self):
self.running = True
self.sent = []
+ self.fast_requests = []
def available(self):
return self.running
- def send(self, sig):
+ def send(self, sig, **identity):
if not self.running:
raise SoftwareOperationError("Updater stopped", 503)
self.sent.append(sig)
+ if sig == "fast":
+ self.fast_requests.append(identity)
def close(self):
pass
@@ -265,6 +268,46 @@ class SoftwareOperationsTest(unittest.TestCase):
self.assertEqual(reader.call_count, 4)
+ def test_fast_current_branch_without_branch_list_or_selected_target(self):
+ self.params.remove("UpdaterAvailableBranches")
+ self.params.put("UpdaterTargetBranch", "release", block=True)
+ self.assertTrue(self.owner.snapshot()["canFastUpdate"])
+ result = self.act("fast", "main")
+ self.assertEqual(result["request"]["target"], "main")
+ self.assertEqual(self.process.sent, ["fast"])
+ self.assertEqual(self.process.fast_requests, [{"branch": "main", "commit": "a" * 40}])
+ self.assertEqual(self.params.get("UpdaterTargetBranch"), "release")
+ self.assertFalse(self.params.get_bool("DoReboot"))
+ self.params.put("LastUpdateTime", datetime(2026, 10, 3, 12), block=True)
+ self.params.put("UpdaterLastFetchTime", datetime(2026, 10, 3, 12), block=True)
+ result = self.owner.snapshot()["request"]
+ self.assertEqual(result["state"], "complete")
+ self.assertEqual(result["outcome"], "up_to_date")
+
+ def test_fast_rejects_other_or_stale_installed_branch(self):
+ self.denied("fast", 409, "release")
+ self.git[self.installed] = ("main", "b" * 40)
+ self.denied("fast", 409, "main")
+ self.git[self.installed] = ("release", "a" * 40)
+ self.denied("fast", 409, "main")
+ self.assertEqual(self.process.sent, [])
+ self.assertFalse(self.params.get_bool("DoReboot"))
+
+ def test_fast_keeps_existing_admission_and_observes_failure(self):
+ for key in ("DisableUpdates", "IsOnroad", "DoReboot"):
+ self.params.put_bool(key, True, block=True)
+ self.denied("fast", 409, "main")
+ self.params.put_bool(key, False, block=True)
+ self.authorized = False
+ self.denied("fast", 403, "main")
+ self.authorized = True
+ self.act("fast", "main")
+ self.denied("fast", 409, "main")
+ self.params.put("UpdateFailedCount", 1, block=True)
+ self.assertEqual(self.owner.snapshot()["request"]["state"], "failed")
+ self.assertEqual(self.process.sent, ["fast"])
+
+
class UpdaterIdentityTest(unittest.TestCase):
def test_fresh_manager_pid_and_same_proc_start_before_signal(self):
with tempfile.TemporaryDirectory() as temporary:
diff --git a/openpilot/starpilot/galaxy/tests/test_software_status.mjs b/openpilot/starpilot/galaxy/tests/test_software_status.mjs
index 2aac50ecef..77f7c0e354 100644
--- a/openpilot/starpilot/galaxy/tests/test_software_status.mjs
+++ b/openpilot/starpilot/galaxy/tests/test_software_status.mjs
@@ -324,3 +324,40 @@ assert(validSoftwareSnapshot(snapshot({ operations: { ...operations, automaticDo
assert(!validSoftwareSnapshot(snapshot({ operations: { ...operations, history: { ...history, installed: [{ hash: '--help' }] } } })))
assert(!validSoftwareSnapshot(snapshot({ operations: { ...operations, automaticDownloads: 'true' } })))
assert.doesNotMatch(SoftwarePage.template, /v-html/)
+
+const fast = fixture()
+fast.feed.start()
+await fast.reply(0, snapshot({ operations: { ...operations, availableBranches: [], selectedTarget: "beta", canFastUpdate: true } }))
+fast.feed.action("fast", "beta")
+assert.equal(fast.requests.length, 1)
+fast.feed.action("fast", installed.branch)
+assert.deepEqual(JSON.parse(fast.requests[1].options.body), { action: "fast", branch: installed.branch })
+await fast.reply(1, snapshot({ operations: { ...operations, canFastUpdate: false, request: request("fast", "pending", installed.branch) }, updater: { ...updater, state: "updating..." } }))
+fast.feed.action("fast", installed.branch)
+assert.equal(fast.requests.length, 2)
+fast.fire(1000)
+await fast.reply(2, snapshot({ operations: { ...operations, canFastUpdate: true, request: { ...request("fast", "complete", installed.branch), outcome: "up_to_date" } } }))
+assert.equal(fast.feed.installBaseline, null)
+assert.match(fast.feed.notice, /already up to date/)
+fast.feed.stop()
+
+const fastLost = fixture()
+fastLost.feed.start()
+await fastLost.reply(0, snapshot({ operations: { ...operations, canFastUpdate: true } }))
+fastLost.feed.action("fast", installed.branch)
+await fastLost.fail(1)
+assert.equal(fastLost.feed.uncertain, true)
+fastLost.feed.action("fast", installed.branch)
+assert.equal(fastLost.requests.length, 2)
+fastLost.fire(2000)
+await fastLost.reply(2, snapshot({ installed: { ...installed, commit: "b".repeat(40) }, operations: { ...operations, canFastUpdate: true } }))
+assert.equal(fastLost.feed.installBaseline, null)
+assert.equal(fastLost.feed.uncertain, false)
+assert.match(fastLost.feed.notice, /verified after reconnect/)
+fastLost.feed.stop()
+
+const fastPage = { actionDisabled: false, operations: { canFastUpdate: true, selectedTarget: "beta" }, data: { installed }, dialog: null }
+SoftwarePage.methods.askFastUpdate.call(fastPage)
+assert.deepEqual(fastPage.dialog, { action: "fast", branch: "Dom", title: "Fast Update", message: "Download latest version of Dom and restart?", label: "Fast Update" })
+assert.equal(validSoftwareSnapshot(snapshot({ operations: { ...operations, canFastUpdate: "yes" } })), false)
+assert.equal(validSoftwareSnapshot(snapshot({ operations: { ...operations, request: { ...request("fast"), outcome: "invented" } } })), false)
diff --git a/openpilot/starpilot/galaxy/web/js/software-status.js b/openpilot/starpilot/galaxy/web/js/software-status.js
index ad068c0f53..5be9923a55 100644
--- a/openpilot/starpilot/galaxy/web/js/software-status.js
+++ b/openpilot/starpilot/galaxy/web/js/software-status.js
@@ -1,11 +1,11 @@
import { decodeLayoutBackup, encodeLayoutBackup, MAX_LAYOUT_BACKUP_BYTES } from "./layout-backup.js"
-const ACTIONS = new Set(["check", "download", "select", "install", "preferences"])
+const ACTIONS = new Set(["check", "download", "select", "install", "preferences", "fast"])
const REQUEST_STATES = new Set(["pending", "complete", "failed"])
-const UPDATER_ACTIVE = new Set(["checking...", "downloading...", "finalizing update..."])
+const UPDATER_ACTIVE = new Set(["checking...", "downloading...", "finalizing update...", "updating..."])
const PRIMARY_BRANCHES = ["StarPilot", "Dom"]
const unavailableOperations = () => ({ parked: false, availableBranches: [], selectedTarget: null,
- canCheck: false, canDownload: false, canSelect: false, canInstall: false,
+ canCheck: false, canFastUpdate: false, canDownload: false, canSelect: false, canInstall: false,
reason: "Update controls are unavailable", request: null })
export function validSoftwareSnapshot(data) {
@@ -22,12 +22,14 @@ export function validSoftwareSnapshot(data) {
operations.availableBranches.length <= 256 && operations.availableBranches.every((branch) =>
typeof branch === "string" && branch.length > 0 && branch.length <= 128) &&
text(operations.selectedTarget) && text(operations.reason) &&
+ (operations.canFastUpdate === undefined || typeof operations.canFastUpdate === "boolean") &&
(operations.automaticDownloads === undefined || flag(operations.automaticDownloads)) &&
(operations.canConfigure === undefined || typeof operations.canConfigure === "boolean") &&
(operations.history === undefined || validHistory(operations.history)) &&
[operations.canCheck, operations.canDownload, operations.canSelect, operations.canInstall].every((value) => typeof value === "boolean") &&
(request === null || !!request && typeof request.id === "string" && request.id.length <= 100 &&
- ACTIONS.has(request.action) && text(request.target) && REQUEST_STATES.has(request.state) && text(request.error)))
+ ACTIONS.has(request.action) && text(request.target) && REQUEST_STATES.has(request.state) && text(request.error) &&
+ (request.outcome === undefined || ["up_to_date", "restarting"].includes(request.outcome))))
}
export function validHistory(value) {
@@ -147,7 +149,9 @@ export class SoftwareStatusFeed {
this.uncertain = false
this.uncertainAction = null
this.actionPriorRequestId = null
- if (body.action === "install") {
+ if (body.action === "fast") {
+ this.notice = `Updating ${body.branch}. Waiting for completion or reconnect.`
+ } else if (body.action === "install") {
this.installBaseline = this.installBaseline || { commit: payload.installed.commit, branch: payload.installed.branch, target: body.branch }
this.notice = "Restart requested. Waiting to reconnect and verify the installed build."
} else this.notice = body.action === "select" ? `Target branch set to ${body.branch}. Check and download the update when ready.` :
@@ -166,6 +170,13 @@ export class SoftwareStatusFeed {
this.uncertainAction = null
this.actionPriorRequestId = null
}
+ if (this.installBaseline?.action === "fast" && observed?.action === "fast" && observed.target === this.installBaseline.target &&
+ ["complete", "failed"].includes(observed.state)) {
+ if (observed.state === "failed" || observed.outcome === "up_to_date") {
+ this.installBaseline = null
+ this.notice = observed.state === "failed" ? "" : "Installed branch is already up to date. No restart needed."
+ } else this.notice = "Update installed. Waiting to reconnect and verify the installed build."
+ }
if (this.installBaseline && payload.installed.branch === this.installBaseline.target &&
(payload.installed.commit && payload.installed.commit !== this.installBaseline.commit ||
payload.installed.branch !== this.installBaseline.branch)) {
@@ -186,7 +197,7 @@ export class SoftwareStatusFeed {
if (body !== null) {
this.uncertain = !error?.rejected
this.uncertainAction = this.uncertain ? body : null
- if (error?.rejected && body.action === "install") this.installBaseline = null
+ if (error?.rejected && ["install", "fast"].includes(body.action)) this.installBaseline = null
}
this.error = error?.message || "Software request failed. Refresh to try again."
this.emit(this.data ? "ready" : this.attempted ? "unavailable" : "loading")
@@ -214,14 +225,16 @@ export class SoftwareStatusFeed {
action(action, branch = null) {
const operations = this.data?.operations
- const capability = { check: "canCheck", download: "canDownload", select: "canSelect", install: "canInstall" }[action]
+ const capability = { check: "canCheck", download: "canDownload", select: "canSelect", install: "canInstall", fast: "canFastUpdate" }[action]
if (!this.active || !capability || this.mutating || this.blocked || this.uncertain || !operations?.parked ||
operations[capability] !== true || operations.request?.state === "pending") return null
if (action === "select" && (branch === "other:" || !operations.availableBranches.includes(branch) || branch === operations.selectedTarget)) return null
if (["download", "install"].includes(action) && (!branch || branch !== operations.selectedTarget)) return null
+ if (action === "fast" && (!branch || branch !== this.data.installed.branch)) return null
this.actionPriorRequestId = operations.request?.id ?? null
if (action === "install") this.installBaseline = { commit: this.data.installed.commit,
branch: this.data.installed.branch, target: branch }
+ if (action === "fast") this.installBaseline = { commit: this.data.installed.commit, branch: this.data.installed.branch, target: branch, action }
return this.run(action === "check" ? { action } : { action, branch })
}
}
@@ -345,6 +358,9 @@ export const SoftwarePage = {
requestMessage(request) {
if (!request) return ""
if (request.state === "failed") return request.error || "Update request failed. Refresh and try again."
+ if (request.action === "fast") return request.state === "pending" ? `Updating ${request.target}…` :
+ request.outcome === "up_to_date" ? "Installed branch is already up to date. No restart needed." :
+ "Update installed. Waiting to reconnect and verify the installed build."
if (request.action === "install") return "Restart requested. Waiting to reconnect and verify the installed build."
if (request.state === "pending") return request.action === "check" ? "Checking for updates…" : "Downloading and preparing update…"
return request.action === "check" ? "Update check finished." : request.action === "download" ? "Download finished." : "Target branch saved."
@@ -354,6 +370,12 @@ export const SoftwarePage = {
this.dialog = { action: "select", branch: this.draftBranch, title: "Change target branch",
message: `Set ${this.draftBranch} as the target branch? This only stages the choice. Check for updates and download separately.`, label: "Set target branch" }
},
+ askFastUpdate() {
+ const branch = this.data?.installed?.branch
+ if (this.actionDisabled || this.operations?.canFastUpdate !== true || !branch) return
+ this.dialog = { action: "fast", branch, title: "Fast Update",
+ message: `Download latest version of ${branch} and restart?`, label: "Fast Update" }
+ },
askInstall() {
if (this.actionDisabled || !this.operations?.canInstall || !this.operations.selectedTarget) return
this.dialog = { action: "install", branch: this.operations.selectedTarget, title: "Restart and install update",
@@ -412,7 +434,8 @@ export const SoftwarePage = {
{{ data.updater.state }}
An update was found for the target branch.
No target change reported by the last check.
-
+
+
- Last checked
- {{ reported(data.updater.lastSuccessAt) }}
- Last download
- {{ reported(data.updater.lastFetchAt) }}
diff --git a/openpilot/starpilot/software/fast_update.py b/openpilot/starpilot/software/fast_update.py
new file mode 100644
index 0000000000..7aaad4a7d6
--- /dev/null
+++ b/openpilot/starpilot/software/fast_update.py
@@ -0,0 +1,83 @@
+import os
+from pathlib import Path
+import re
+import subprocess
+
+from openpilot.common.vendor_manifest import validate_revision
+from openpilot.common.prebuilt_manifest import revision_digest, valid_receipt
+
+
+class FastUpdateError(RuntimeError):
+ pass
+
+
+def run(command, cwd):
+ environment = dict(os.environ, GIT_LFS_SKIP_SMUDGE='1', GIT_TERMINAL_PROMPT='0')
+ return subprocess.check_output(command, cwd=cwd, env=environment, stderr=subprocess.STDOUT,
+ timeout=120 if "fetch" in command or "reset" in command else 30).decode()
+
+
+def fast_update(repo, branch, *, params, parked, expected_commit, current_os=None, invalidate=None, run=run):
+ repo = Path(repo)
+
+ def git(*arguments):
+ return run(['git', '-c', 'gc.auto=0', '-c', 'maintenance.auto=false', '-c', 'core.hooksPath=/dev/null', '-c', 'submodule.recurse=false', '-c', 'filter.lfs.required=false',
+ '-c', 'filter.lfs.smudge=', '-c', 'filter.lfs.process=', *arguments], cwd=str(repo))
+
+ def admitted():
+ if not parked():
+ raise FastUpdateError('Fast Update requires fresh parked state')
+ if git('symbolic-ref', '--short', 'HEAD').strip() != branch:
+ raise FastUpdateError('Fast Update only updates the current branch')
+ if git('status', '--porcelain', '--untracked-files=no').strip():
+ raise FastUpdateError('Tracked source has local changes')
+
+ git('check-ref-format', '--branch', branch)
+ admitted()
+ previous = git('rev-parse', 'HEAD^{commit}').strip()
+ if not isinstance(expected_commit, str) or not re.fullmatch(r'[0-9a-f]{40}', expected_commit) or previous != expected_commit:
+ raise FastUpdateError('Approved source revision changed; refresh and try again')
+ git('fetch', '--depth=1', '--no-tags', '--no-recurse-submodules', 'origin', f'refs/heads/{branch}')
+ target = git('rev-parse', 'FETCH_HEAD^{commit}').strip()
+ validate_revision(repo, target)
+ launch = git('show', f'{target}:launch_env.sh')
+ versions = re.findall(r'^\s*export\s+AGNOS_VERSION=["\']([^"\'\n]+)["\']\s*$', launch, re.MULTILINE)
+ if len(versions) != 1 or not current_os or versions[0] != current_os:
+ raise FastUpdateError('Update requires a different or unverified operating system')
+ if target == previous:
+ return 'up-to-date'
+ for revision in (previous, target):
+ if git('ls-tree', revision, '--', 'prebuilt').strip().split()[:1] != ['100644']:
+ raise FastUpdateError('Fast Update requires prebuilt releases; use a full validated update')
+ if not valid_receipt(git, target) and revision_digest(git, previous) != revision_digest(git, target):
+ raise FastUpdateError('Native build inputs or artifacts changed without a matching build receipt; use a full validated update')
+ tracked = set(git('ls-tree', '-rz', '--name-only', target).split('\0')) - {''}
+ untracked = set(git('ls-files', '--others', '-z').split('\0')) - {''}
+ directories = {str(parent) for name in tracked for parent in Path(name).parents if str(parent) != '.'}
+ for name in untracked:
+ if name in tracked or name in directories or any(str(parent) in tracked for parent in Path(name).parents):
+ raise FastUpdateError('Update would overwrite untracked data')
+ admitted()
+ if git('rev-parse', 'HEAD^{commit}').strip() != previous:
+ raise FastUpdateError('Source changed during download')
+ if invalidate is None:
+ raise FastUpdateError('Update staging invalidation is unavailable')
+ git('update-ref', 'refs/starpilot/previous', previous)
+ invalidate()
+ admitted()
+ if git('rev-parse', 'HEAD^{commit}').strip() != previous:
+ raise FastUpdateError('Source changed before installation')
+ try:
+ git('reset', '--hard', '--no-recurse-submodules', target)
+ if git('rev-parse', 'HEAD^{commit}').strip() != target:
+ raise FastUpdateError('Applied source revision does not match download')
+ validate_revision(repo, 'HEAD')
+ if not parked():
+ raise FastUpdateError('Parked state changed before restart')
+ params.put_bool('DoReboot', True, block=True)
+ except Exception:
+ git('reset', '--hard', '--no-recurse-submodules', previous)
+ if git('rev-parse', 'HEAD^{commit}').strip() != previous:
+ raise FastUpdateError('Source rollback failed')
+ raise
+ return 'reboot-requested'
diff --git a/openpilot/starpilot/software/tests/test_fast_update.py b/openpilot/starpilot/software/tests/test_fast_update.py
new file mode 100644
index 0000000000..7139b00f18
--- /dev/null
+++ b/openpilot/starpilot/software/tests/test_fast_update.py
@@ -0,0 +1,279 @@
+import importlib.util
+import json
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch
+
+
+ROOT = Path(__file__).resolve().parents[3]
+
+
+def load(name, path):
+ spec = importlib.util.spec_from_file_location(name, path)
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+vendor = load('openpilot.common.vendor_manifest', ROOT / 'common/vendor_manifest.py')
+prebuilt = load('openpilot.common.prebuilt_manifest', ROOT / 'common/prebuilt_manifest.py')
+owner = load('fast_update_owner', ROOT / 'starpilot/software/fast_update.py')
+
+
+class Params:
+ def __init__(self):
+ self.calls = []
+
+ def put_bool(self, *args, **kwargs):
+ self.calls.append((args, kwargs))
+
+
+class TestFastUpdate(unittest.TestCase):
+ def git(self, repo, *args):
+ return subprocess.check_output(['git', '-C', str(repo), *args], stderr=subprocess.STDOUT).decode().strip()
+
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp.cleanup)
+ base = Path(self.temp.name)
+ self.remote, self.repo = base / 'remote', base / 'repo'
+ self.remote.mkdir()
+ self.git(self.remote, 'init', '-b', 'Dom')
+ self.git(self.remote, 'config', 'user.name', 'Test')
+ self.git(self.remote, 'config', 'user.email', 'test@example.invalid')
+ dependencies = []
+ for name in sorted(vendor.DEPENDENCIES):
+ (self.remote / name).mkdir()
+ (self.remote / name / 'source').write_text('vendored')
+ dependencies.append(dict(path=name, commit='1' * 40, tree='2' * 40, url='https://example.invalid/source', exclude=[]))
+ manifest = dict(schema_version=1, upstream=dict(commit='3' * 40, url='https://example.invalid/upstream'), dependencies=dependencies)
+ (self.remote / 'upstream-sync.json').write_text(json.dumps(manifest))
+ (self.remote / 'launch_env.sh').write_text('export AGNOS_VERSION="19.8.1"\n')
+ (self.remote / 'prebuilt').write_text('')
+ (self.remote / 'openpilot/starpilot/ui').mkdir(parents=True)
+ (self.remote / 'openpilot/starpilot/ui/source.py').write_text('old')
+ self.git(self.remote, 'add', '.')
+ self.git(self.remote, 'commit', '-m', 'initial')
+ self.git(base, 'clone', '--no-local', str(self.remote), str(self.repo))
+ self.previous = self.git(self.repo, 'rev-parse', 'HEAD')
+ (self.remote / 'openpilot/starpilot/ui/source.py').write_text('new')
+ self.git(self.remote, 'commit', '-am', 'update')
+ self.target = self.git(self.remote, 'rev-parse', 'HEAD')
+ self.params = Params()
+ self.invalidations = []
+
+ def update(self, **kwargs):
+ arguments = dict(params=self.params, parked=lambda: True, expected_commit=self.git(self.repo, 'rev-parse', 'HEAD'), current_os='19.8.1',
+ invalidate=lambda: self.invalidations.append(True))
+ arguments.update(kwargs)
+ return owner.fast_update(self.repo, 'Dom', **arguments)
+
+ def test_applies_exact_revision_and_retains_untracked_models(self):
+ (self.repo / 'models').mkdir()
+ (self.repo / 'models' / 'download').write_bytes(b'model')
+ commands = []
+ def traced(command, cwd):
+ commands.append(command)
+ return owner.run(command, cwd)
+ self.assertEqual(self.update(run=traced), 'reboot-requested')
+ fetch = next(command for command in commands if 'fetch' in command)
+ self.assertIn('gc.auto=0', fetch)
+ self.assertIn('maintenance.auto=false', fetch)
+ self.assertIn('--depth=1', fetch)
+ self.assertIn('--no-tags', fetch)
+ self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.target)
+ self.assertEqual(self.git(self.repo, 'rev-parse', 'refs/starpilot/previous'), self.previous)
+ self.assertEqual((self.repo / 'models' / 'download').read_bytes(), b'model')
+ self.assertEqual(self.params.calls, [(('DoReboot', True), dict(block=True))])
+ self.assertEqual(self.update(), 'up-to-date')
+ self.assertEqual(len(self.invalidations), 1)
+
+ def test_expected_commit_changed_rejected_before_fetch(self):
+ commands = []
+ def traced(command, cwd):
+ commands.append(command)
+ return owner.run(command, cwd)
+ with self.assertRaises(owner.FastUpdateError):
+ self.update(expected_commit='1' * 40, run=traced)
+ self.assertFalse(any('fetch' in command for command in commands))
+
+ def test_source_only_ui_update_admitted(self):
+ folder = self.remote / 'openpilot/starpilot/ui'
+ folder.mkdir(parents=True, exist_ok=True)
+ (folder / 'runtime_app.py').write_text('value = 1')
+ self.git(self.remote, 'add', '.')
+ self.git(self.remote, 'commit', '-m', 'UI update')
+ self.assertEqual(self.update(), 'reboot-requested')
+
+ def test_native_source_change_rejected_without_artifact_proof(self):
+ (self.remote / 'native.cc').write_text('int main() {}')
+ self.git(self.remote, 'add', '.')
+ self.git(self.remote, 'commit', '-m', 'native update')
+ with self.assertRaisesRegex(owner.FastUpdateError, 'full validated update'):
+ self.update()
+ self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
+ self.assertFalse(self.invalidations)
+
+ def receipt_commit(self):
+ prebuilt.write_receipt(self.remote)
+ self.git(self.remote, 'add', 'prebuilt.json')
+ self.git(self.remote, 'commit', '-m', 'validated receipt')
+
+ def test_matching_receipt_admits_native_source_and_artifacts(self):
+ (self.remote / 'native.cc').write_text('int source;')
+ (self.remote / 'native.so').write_bytes(b'compiled artifact fixture')
+ self.git(self.remote, 'add', '.')
+ self.receipt_commit()
+ self.assertEqual(self.update(), 'reboot-requested')
+
+ def test_receipt_stale_source_rejected(self):
+ (self.remote / 'native.cc').write_text('int source;')
+ self.git(self.remote, 'add', '.')
+ self.receipt_commit()
+ (self.remote / 'native.cc').write_text('int changed;')
+ self.git(self.remote, 'commit', '-am', 'stale source')
+ with self.assertRaisesRegex(owner.FastUpdateError, 'matching build receipt'):
+ self.update()
+ self.assertFalse(self.invalidations)
+
+ def test_receipt_stale_artifact_rejected(self):
+ (self.remote / 'native.so').write_bytes(b'artifact')
+ self.git(self.remote, 'add', '.')
+ self.receipt_commit()
+ (self.remote / 'native.so').write_bytes(b'changed artifact')
+ self.git(self.remote, 'commit', '-am', 'stale artifact')
+ with self.assertRaisesRegex(owner.FastUpdateError, 'matching build receipt'):
+ self.update()
+ self.assertFalse(self.invalidations)
+
+ def test_receipt_working_content_and_modes_match_committed_tree(self):
+ (self.remote / 'native.cc').write_text('int unstaged;')
+ self.git(self.remote, 'add', '.')
+ (self.remote / 'native.cc').write_text('int working;')
+ (self.remote / 'native.cc').chmod(0o755)
+ (self.remote / 'native-link').symlink_to('native.cc')
+ self.git(self.remote, 'add', 'native-link')
+ prebuilt.write_receipt(self.remote)
+ self.git(self.remote, 'add', '.')
+ self.git(self.remote, 'commit', '-m', 'receipt content and modes')
+ git = lambda *args: subprocess.check_output(['git', '-C', str(self.remote), *args]).decode()
+ self.assertTrue(prebuilt.valid_receipt(git, 'HEAD'))
+
+ def test_missing_prebuilt_rejected_without_removing_local_marker(self):
+ self.git(self.remote, 'rm', 'prebuilt')
+ self.git(self.remote, 'commit', '-m', 'nonprebuilt')
+ with self.assertRaisesRegex(owner.FastUpdateError, 'prebuilt'):
+ self.update()
+ self.assertTrue((self.repo / 'prebuilt').exists())
+
+ def test_wrong_os_rejected_before_mutation(self):
+ with self.assertRaises(owner.FastUpdateError):
+ self.update(current_os='19.8.2')
+ self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
+ self.assertFalse(self.invalidations)
+ self.assertFalse(self.params.calls)
+
+ def test_dirty_tracked_source_rejected(self):
+ (self.repo / 'openpilot/starpilot/ui/source.py').write_text('local repair')
+ with self.assertRaises(owner.FastUpdateError):
+ self.update()
+ self.assertEqual((self.repo / 'openpilot/starpilot/ui/source.py').read_text(), 'local repair')
+
+ def test_staged_source_rejected(self):
+ (self.repo / 'openpilot/starpilot/ui/source.py').write_text('staged repair')
+ self.git(self.repo, 'add', 'openpilot/starpilot/ui/source.py')
+ with self.assertRaises(owner.FastUpdateError):
+ self.update()
+ self.assertEqual((self.repo / 'openpilot/starpilot/ui/source.py').read_text(), 'staged repair')
+ self.assertFalse(self.invalidations)
+
+ def test_reboot_write_failure_rolls_back(self):
+ def failing(*args, **kwargs):
+ raise RuntimeError('params write failed')
+ self.params.put_bool = failing
+ with self.assertRaisesRegex(RuntimeError, 'params write failed'):
+ self.update()
+ self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
+
+ def test_fetch_failure_does_not_mutate(self):
+ def failing(command, cwd):
+ if 'fetch' in command:
+ raise RuntimeError('fetch failed')
+ return owner.run(command, cwd)
+ with self.assertRaisesRegex(RuntimeError, 'fetch failed'):
+ self.update(run=failing)
+ self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
+ self.assertFalse(self.invalidations)
+
+ def test_apply_failure_rolls_back(self):
+ failed = False
+ def failing(command, cwd):
+ nonlocal failed
+ if 'reset' in command and command[-1] == self.target and not failed:
+ failed = True
+ owner.run(command, cwd)
+ raise RuntimeError('apply failed after reset')
+ return owner.run(command, cwd)
+ with self.assertRaisesRegex(RuntimeError, 'apply failed'):
+ self.update(run=failing)
+ self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
+ self.assertEqual((self.repo / 'openpilot/starpilot/ui/source.py').read_text(), 'old')
+ self.assertFalse(self.params.calls)
+
+ def test_lost_parked_before_reboot_rolls_back(self):
+ states = iter([True, True, True, False])
+ with self.assertRaises(owner.FastUpdateError):
+ self.update(parked=lambda: next(states))
+ self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
+ self.assertFalse(self.params.calls)
+
+ def test_lost_parked_before_source_mutation(self):
+ states = iter([True, False])
+ with self.assertRaises(owner.FastUpdateError):
+ self.update(parked=lambda: next(states))
+ self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
+ self.assertFalse(self.invalidations)
+
+ def test_invalid_vendor_target_rejected_before_checkout(self):
+ (self.remote / 'upstream-sync.json').write_text('{}')
+ self.git(self.remote, 'commit', '-am', 'invalid')
+ with self.assertRaises(ValueError):
+ self.update()
+ self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
+ self.assertFalse(self.invalidations)
+
+ def test_other_branch_rejected(self):
+ self.git(self.repo, 'checkout', '-b', 'Other')
+ with self.assertRaises(owner.FastUpdateError):
+ self.update()
+ self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
+
+ def test_untracked_collision_rejected(self):
+ (self.repo / 'new-model').write_bytes(b'model')
+ (self.remote / 'new-model').write_text('tracked update')
+ self.git(self.remote, 'add', 'new-model')
+ self.receipt_commit()
+ with self.assertRaisesRegex(owner.FastUpdateError, 'overwrite untracked data'):
+ self.update()
+ self.assertEqual((self.repo / 'new-model').read_bytes(), b'model')
+ self.assertFalse(self.invalidations)
+
+ def test_post_apply_validation_failure_rolls_back(self):
+ original = owner.validate_revision
+ def validate(repo, revision):
+ if revision == 'HEAD':
+ raise ValueError('post-check failed')
+ return original(repo, revision)
+ with patch.object(owner, 'validate_revision', validate):
+ with self.assertRaisesRegex(ValueError, 'post-check failed'):
+ self.update()
+ self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
+ self.assertFalse(self.params.calls)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/openpilot/starpilot/software/tests/test_update_control.py b/openpilot/starpilot/software/tests/test_update_control.py
index a51bf04e57..acc86d99c2 100644
--- a/openpilot/starpilot/software/tests/test_update_control.py
+++ b/openpilot/starpilot/software/tests/test_update_control.py
@@ -14,6 +14,47 @@ class UpdaterControlTests(unittest.TestCase):
with self.assertRaises(update_control.UpdaterControlError):
update_control.send(0, 0, 'install')
+ def test_fast_wire_pins_branch_and_commit_without_changing_legacy_commands(self):
+ commit = 'a' * 40
+ with mock.patch.object(update_control, '_exchange') as exchange:
+ update_control.send(123, 456, 'fast', branch='SecretGoodStarPilot', commit=commit)
+ exchange.assert_called_once_with(123, 456, b'fast SecretGoodStarPilot ' + commit.encode())
+ self.assertEqual(update_control._parse(b'fast SecretGoodStarPilot ' + commit.encode() + b'\n'),
+ ('fast', 'SecretGoodStarPilot', commit))
+ for action in ('check', 'download'):
+ self.assertEqual(update_control._command(action), action.encode())
+ self.assertEqual(update_control._parse(action.encode() + b'\n'), (action, None, None))
+ self.assertLessEqual(len(update_control._command('fast', 'x' * 128, commit)) + 1, update_control.MAX_COMMAND)
+ for raw in (b'fast\n', b'fast Dom bad\n', b'fast Dom ' + commit.encode(),
+ b'fast Dom ' + commit.encode() + b' extra\n', b'fast Dom\ncheck ' + commit.encode() + b'\n'):
+ with self.subTest(raw=raw), self.assertRaises(update_control.UpdaterControlError):
+ update_control._parse(raw)
+ for branch, revision in ((None, commit), ('Dom', None), ('Dom\ncheck', commit), ('x' * 129, commit), ('Dom', 'A' * 40)):
+ with self.subTest(branch=branch), self.assertRaises(update_control.UpdaterControlError):
+ update_control.send(123, 456, 'fast', branch=branch, commit=revision)
+ with self.assertRaises(update_control.UpdaterControlError):
+ update_control.send(123, 456, 'check', branch='Dom', commit=commit)
+
+ def test_server_delivers_fast_identity_and_legacy_callback_shape(self):
+ for raw, expected in ((b'fast Dom ' + b'a' * 40 + b'\n', mock.call('fast', branch='Dom', commit='a' * 40)),
+ (b'check\n', mock.call('check')), (b'download\n', mock.call('download'))):
+ with self.subTest(raw=raw):
+ client, connection = socket.socketpair()
+ try:
+ client.settimeout(1)
+ client.sendall(raw)
+ callback = mock.Mock()
+ listener = mock.Mock()
+ listener.accept.side_effect = [(connection, None), OSError()]
+ server = update_control.UpdaterControlServer(callback)
+ with mock.patch.object(update_control, '_peer', return_value=(os.getpid(), os.geteuid())):
+ server._run(listener)
+ self.assertEqual(client.recv(16), b'ok\n')
+ self.assertEqual(callback.call_args, expected)
+ finally:
+ client.close()
+ connection.close()
+
@unittest.skipUnless(sys.platform.startswith('linux') and hasattr(socket, 'SO_PEERCRED'), 'Linux peer credentials required')
def test_same_process_control_and_stale_identity(self):
actions = []
diff --git a/openpilot/starpilot/software/update_control.py b/openpilot/starpilot/software/update_control.py
index c0d6838201..4304f3fb00 100644
--- a/openpilot/starpilot/software/update_control.py
+++ b/openpilot/starpilot/software/update_control.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import os
+import re
import socket
import struct
import threading
@@ -11,7 +12,9 @@ from pathlib import Path
TIMEOUT = 0.5
-MAX_COMMAND = 16
+MAX_COMMAND = 256
+_BRANCH = re.compile(r"[A-Za-z0-9_][A-Za-z0-9._/+@-]{0,127}\Z")
+_COMMIT = re.compile(r"[0-9a-f]{40}\Z")
_CREDENTIALS = struct.Struct('3i')
@@ -78,14 +81,32 @@ def available(pid: int, start: int) -> bool:
return False
-def send(pid: int, start: int, action: str) -> None:
- if action not in ('check', 'download'):
- raise UpdaterControlError('Invalid updater control action')
- _exchange(pid, start, action.encode('ascii'))
+def _command(action: str, branch=None, commit=None) -> bytes:
+ if action in ('check', 'download') and branch is None and commit is None:
+ return action.encode('ascii')
+ if action == 'fast' and type(branch) is str and type(commit) is str and _BRANCH.fullmatch(branch) and _COMMIT.fullmatch(commit):
+ return f'fast {branch} {commit}'.encode('ascii')
+ raise UpdaterControlError('Invalid updater control action')
+
+
+def _parse(command: bytes):
+ if command in (b'status\n', b'check\n', b'download\n'):
+ return command[:-1].decode('ascii'), None, None
+ try:
+ action, branch, commit = command[:-1].decode('ascii').split(' ')
+ except (UnicodeError, ValueError):
+ raise UpdaterControlError('Invalid updater control message') from None
+ if not command.endswith(b'\n') or action != 'fast' or _command(action, branch, commit) + b'\n' != command:
+ raise UpdaterControlError('Invalid updater control message')
+ return action, branch, commit
+
+
+def send(pid: int, start: int, action: str, *, branch=None, commit=None) -> None:
+ _exchange(pid, start, _command(action, branch, commit))
class UpdaterControlServer:
- def __init__(self, request: Callable[[str], None]):
+ def __init__(self, request: Callable[..., None]):
self.request = request
self.socket: socket.socket | None = None
self.thread: threading.Thread | None = None
@@ -123,13 +144,21 @@ class UpdaterControlServer:
try:
_, uid = _peer(connection)
command = _line(connection)
- if uid != os.geteuid() or command not in (b'status\n', b'check\n', b'download\n'):
+ if uid != os.geteuid():
connection.sendall(b'error\n')
else:
- if command != b'status\n':
- self.request(command[:-1].decode('ascii'))
+ action, branch, commit = _parse(command)
+ if action == 'fast':
+ self.request(action, branch=branch, commit=commit)
+ elif action != 'status':
+ self.request(action)
connection.sendall(b'ok\n')
- except (OSError, RuntimeError, ValueError):
+ except (RuntimeError, ValueError):
+ try:
+ connection.sendall(b'error\n')
+ except OSError:
+ pass
+ except OSError:
pass
def close(self) -> None:
diff --git a/openpilot/system/updated/tests/test_fast_request.py b/openpilot/system/updated/tests/test_fast_request.py
new file mode 100644
index 0000000000..825d1269f5
--- /dev/null
+++ b/openpilot/system/updated/tests/test_fast_request.py
@@ -0,0 +1,152 @@
+from pathlib import Path
+import tempfile
+import threading
+import sys
+from types import ModuleType
+import unittest
+from unittest.mock import Mock, patch
+
+from openpilot.system.updated.tests.test_vendored_update import load_updater
+
+
+class StopLoop(Exception):
+ pass
+
+
+class TestFastRequest(unittest.TestCase):
+ def setUp(self):
+ self.updated = load_updater()
+ self.directory = tempfile.TemporaryDirectory()
+ self.addCleanup(self.directory.cleanup)
+ root = Path(self.directory.name)
+ self.updated.LOCK_FILE = str(root / 'lock')
+ self.updated.STAGING_ROOT = str(root / 'staging')
+ self.updated.OVERLAY_INIT = root / 'overlay'
+ self.params = Mock()
+ self.values = {'InstallDate': '2026-10-03', 'UpdateFailedCount': 2}
+ self.writes = []
+ self.params.get.side_effect = lambda key, **kwargs: self.values.get(key)
+ self.params.get_bool.side_effect = lambda key: bool(self.values.get(key, False))
+ def put(key, value, **kwargs):
+ self.writes.append((key, value))
+ self.values[key] = value
+ self.params.put.side_effect = put
+ self.params.put_bool.side_effect = put
+ self.params.remove.side_effect = lambda key: self.values.pop(key, None)
+ self.updated.Params = Mock(return_value=self.params)
+ self.helper = self.updated.WaitTimeHelper.__new__(self.updated.WaitTimeHelper)
+ self.helper.ready_event = threading.Event()
+ self.helper.request_lock = threading.RLock()
+ self.helper.request_generation = 0
+ self.helper.fast_target = None
+ self.helper.user_request = self.updated.UserRequest.NONE
+ self.helper._control_request('fast', branch='SecretGoodStarPilot', commit='a' * 40)
+ self.helper.sleep = Mock(side_effect=StopLoop)
+ self.updated.WaitTimeHelper = Mock(return_value=self.helper)
+ self.updater = Mock()
+ self.updater.fast_update.return_value = 'up-to-date'
+ self.updated.Updater = Mock(return_value=self.updater)
+ self.updated.init_overlay = Mock()
+ self.updated.set_consistent_flag = Mock()
+
+ def run_cycle(self):
+ with self.assertRaises(StopLoop):
+ self.updated.main()
+ self.updated.init_overlay.assert_not_called()
+ self.updater.check_for_update.assert_not_called()
+ self.updater.fetch_update.assert_not_called()
+ self.updater.set_params.assert_not_called()
+ self.updated.system_time_valid.assert_not_called()
+ self.updater.fast_update.assert_called_once_with('SecretGoodStarPilot', 'a' * 40)
+
+ def test_fast_request_skips_staging_and_startup_delay(self):
+ self.run_cycle()
+ self.assertEqual(self.values['UpdaterState'], 'idle')
+ self.assertEqual(self.values['UpdateFailedCount'], 0)
+ self.assertIn('UpdaterLastFetchTime', self.values)
+ self.assertIn('LastUpdateTime', self.values)
+ self.assertNotIn('DoReboot', self.values)
+ self.assertLess(self.writes.index(('UpdateFailedCount', 0)), len(self.writes) - 1)
+ self.assertEqual(self.writes[-1], ('UpdaterState', 'idle'))
+
+ def test_fast_failure_reports_error_without_reboot(self):
+ self.updater.fast_update.side_effect = RuntimeError('Tracked source has local changes')
+ self.run_cycle()
+ self.assertEqual(self.values['UpdaterState'], 'idle')
+ self.assertEqual(self.values['UpdateFailedCount'], 3)
+ self.assertEqual(self.values['LastUpdateException'], 'Tracked source has local changes')
+ self.assertNotIn('DoReboot', self.values)
+ self.assertNotIn('LastUpdateTime', self.values)
+
+ def test_long_failure_remains_readable_by_galaxy(self):
+ self.updater.fast_update.side_effect = RuntimeError('download failed: ' + 'é' * 5000)
+ self.run_cycle()
+ self.assertLessEqual(len(self.values['LastUpdateException'].encode('utf-8')), 4096)
+ self.assertTrue(self.values['LastUpdateException'].startswith('download failed: '))
+ self.assertEqual(self.values['UpdaterState'], 'idle')
+
+ def test_success_waits_for_manager_restart(self):
+ def apply(branch, commit):
+ self.params.put_bool('DoReboot', True, block=True)
+ return 'reboot-requested'
+ self.updater.fast_update.side_effect = apply
+ self.run_cycle()
+ self.assertEqual(self.values['UpdaterState'], 'updating...')
+ self.assertTrue(self.values['DoReboot'])
+
+ def test_new_request_is_not_lost_when_previous_work_finishes(self):
+ _, generation, target = self.helper.current_request()
+ self.assertEqual(target, ('SecretGoodStarPilot', 'a' * 40))
+ self.helper._control_request('check')
+ self.assertFalse(self.helper.finish_request(generation))
+ self.assertTrue(self.helper.ready_event.is_set())
+ request, generation, target = self.helper.current_request()
+ self.assertIsNone(target)
+ self.assertEqual(request, self.updated.UserRequest.CHECK)
+ self.assertTrue(self.helper.finish_request(generation))
+ self.assertEqual(self.helper.user_request, self.updated.UserRequest.NONE)
+ self.assertFalse(self.helper.ready_event.is_set())
+
+
+class TestFastAdapter(unittest.TestCase):
+ def test_source_admission_and_overlay_invalidation_are_owned_by_updater(self):
+ updated = load_updater()
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ updated.FINALIZED = str(root / 'finalized')
+ Path(updated.FINALIZED).mkdir()
+ marker = Path(updated.FINALIZED, '.overlay_consistent')
+ marker.touch()
+ updated.OVERLAY_INIT = root / '.overlay_init'
+ updated.OVERLAY_INIT.touch()
+ updated.BASEDIR = str(root)
+ updated.dismount_overlay = Mock()
+ updated.HARDWARE.get_os_version.return_value = '19.8.1'
+ updater = updated.Updater()
+ updater.params = Mock()
+ updater.params.get_bool.side_effect = lambda key: key == 'IsOffroad'
+ source = Mock()
+ source.allowed.return_value = True
+ source.effective.return_value = False
+ physical = ModuleType('openpilot.starpilot.drive_state.evidence')
+ physical.PhysicalSource = Mock(return_value=source)
+ owner = ModuleType('openpilot.starpilot.software.fast_update')
+ def install(repo, branch, **options):
+ self.assertEqual((repo, branch, options['expected_commit'], options['current_os']),
+ (root, 'SecretGoodStarPilot', 'b' * 40, '19.8.1'))
+ self.assertTrue(options['parked']())
+ self.assertTrue(marker.exists())
+ options['invalidate']()
+ self.assertFalse(marker.exists())
+ self.assertFalse(updated.OVERLAY_INIT.exists())
+ updated.dismount_overlay.assert_called_once()
+ return 'reboot-requested'
+ owner.fast_update = install
+ with patch.dict(sys.modules, {physical.__name__: physical, owner.__name__: owner}):
+ self.assertEqual(updater.fast_update('SecretGoodStarPilot', 'b' * 40), 'reboot-requested')
+ source.close.assert_called_once()
+ updater.params.put_bool.assert_called_once_with('UpdateAvailable', False, block=True)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/openpilot/system/updated/tests/test_unpublished_branch.py b/openpilot/system/updated/tests/test_unpublished_branch.py
index 56917d1692..e9a73d0550 100644
--- a/openpilot/system/updated/tests/test_unpublished_branch.py
+++ b/openpilot/system/updated/tests/test_unpublished_branch.py
@@ -38,7 +38,7 @@ class TestUnpublishedBranch(unittest.TestCase):
def run(command, cwd):
self.assertEqual(cwd, str(self.merged))
self.commands.append(command)
- if command == ["git", "ls-remote", "--heads"]:
+ if command == ["git", "ls-remote", "--heads", "origin"]:
return self.remote_heads
if command == ["git", "ls-remote", "origin", "HEAD"]:
return f"{REMOTE}\tHEAD\n"
@@ -55,7 +55,7 @@ class TestUnpublishedBranch(unittest.TestCase):
self.assertFalse(self.updater.update_ready)
self.assertFalse(self.updater.fetch_update())
self.assertNotIn(TARGET, self.updater.branches)
- self.assertEqual(self.commands.count(["git", "ls-remote", "--heads"]), 1)
+ self.assertEqual(self.commands.count(["git", "ls-remote", "--heads", "origin"]), 1)
self.updated.set_consistent_flag.assert_not_called()
self.assertNotIn("UpdaterState", self.updater.params.values)
@@ -94,7 +94,7 @@ class TestUnpublishedBranch(unittest.TestCase):
self.assertTrue(self.updater.update_available)
def failed_run(command, cwd):
- if command == ["git", "ls-remote", "--heads"]:
+ if command == ["git", "ls-remote", "--heads", "origin"]:
raise subprocess.CalledProcessError(128, command, output="network failed")
return f"{REMOTE}\tHEAD\n"
diff --git a/openpilot/system/updated/updated.py b/openpilot/system/updated/updated.py
index bca8b62f5e..e57599fd57 100755
--- a/openpilot/system/updated/updated.py
+++ b/openpilot/system/updated/updated.py
@@ -45,11 +45,15 @@ class UserRequest:
NONE = 0
CHECK = 1
FETCH = 2
+ FAST = 3
class WaitTimeHelper:
def __init__(self):
self.ready_event = threading.Event()
self.user_request = UserRequest.NONE
+ self.request_lock = threading.RLock()
+ self.request_generation = 0
+ self.fast_target = None
signal.signal(signal.SIGHUP, self.update_now)
signal.signal(signal.SIGUSR1, self.check_now)
self.control = UpdaterControlServer(self._control_request)
@@ -60,24 +64,39 @@ class WaitTimeHelper:
else:
atexit.register(self.control.close)
- def _control_request(self, action: str) -> None:
- if action == 'check':
- self.user_request = UserRequest.CHECK
- elif action == 'download':
- self.user_request = UserRequest.FETCH
- else:
- return
- self.ready_event.set()
+ def _request(self, request: int, *, branch=None, commit=None) -> None:
+ with self.request_lock:
+ self.user_request = request
+ self.fast_target = (branch, commit) if request == UserRequest.FAST else None
+ self.request_generation += 1
+ self.ready_event.set()
+
+ def _control_request(self, action: str, *, branch=None, commit=None) -> None:
+ request = {'check': UserRequest.CHECK, 'download': UserRequest.FETCH, 'fast': UserRequest.FAST}.get(action)
+ if request is not None:
+ self._request(request, branch=branch, commit=commit)
def update_now(self, signum: int, frame) -> None:
cloudlog.info("caught SIGHUP, attempting to downloading update")
- self.user_request = UserRequest.FETCH
- self.ready_event.set()
+ self._request(UserRequest.FETCH)
def check_now(self, signum: int, frame) -> None:
cloudlog.info("caught SIGUSR1, checking for updates")
- self.user_request = UserRequest.CHECK
- self.ready_event.set()
+ self._request(UserRequest.CHECK)
+
+ def current_request(self) -> tuple[int, int, tuple[str, str] | None]:
+ with self.request_lock:
+ self.ready_event.clear()
+ return self.user_request, self.request_generation, self.fast_target
+
+ def finish_request(self, generation: int) -> bool:
+ with self.request_lock:
+ if self.request_generation != generation:
+ return False
+ self.user_request = UserRequest.NONE
+ self.fast_target = None
+ self.ready_event.clear()
+ return True
def sleep(self, t: float) -> None:
self.ready_event.wait(timeout=t)
@@ -374,15 +393,14 @@ class Updater:
excluded_branches = ('release2', 'release2-staging')
- try:
- run(["git", "ls-remote", "origin", "HEAD"], OVERLAY_MERGED)
- self._has_internet = True
- except subprocess.CalledProcessError:
- self._has_internet = False
-
self._branches_checked = False
setup_git_options(OVERLAY_MERGED)
- output = run(["git", "ls-remote", "--heads"], OVERLAY_MERGED)
+ try:
+ output = run(["git", "ls-remote", "--heads", "origin"], OVERLAY_MERGED)
+ except subprocess.CalledProcessError:
+ self._has_internet = False
+ raise
+ self._has_internet = True
self.branches.clear()
for line in output.split('\n'):
@@ -403,6 +421,35 @@ class Updater:
else:
cloudlog.info(f"up to date on {cur_branch} ({str(cur_commit)[:7]})")
+ def fast_update(self, branch: str, commit: str) -> str:
+ from openpilot.starpilot.drive_state.evidence import PhysicalSource
+ from openpilot.starpilot.software.fast_update import fast_update
+
+ source = PhysicalSource()
+
+ def parked():
+ deadline = time.monotonic() + 0.75
+ while True:
+ if (self.params.get_bool("IsOffroad") and not self.params.get_bool("IsOnroad") and
+ source.allowed() and source.effective() is False):
+ return True
+ if time.monotonic() >= deadline:
+ return False
+ time.sleep(0.02)
+
+ def invalidate():
+ Path(FINALIZED, ".overlay_consistent").unlink(missing_ok=True)
+ OVERLAY_INIT.unlink(missing_ok=True)
+ dismount_overlay()
+ self.params.put_bool("UpdateAvailable", False, block=True)
+ self._branches_checked = False
+
+ try:
+ return fast_update(Path(BASEDIR), branch, expected_commit=commit, params=self.params, parked=parked,
+ current_os=HARDWARE.get_os_version(), invalidate=invalidate)
+ finally:
+ source.close()
+
def fetch_update(self) -> bool:
if not self._branches_checked:
self.check_for_update()
@@ -475,7 +522,7 @@ def main() -> None:
params.put("InstallDate", t, block=True)
updater = Updater()
- update_failed_count = 0 # TODO: Load from param?
+ update_failed_count = max(0, params.get("UpdateFailedCount") or 0)
wait_helper = WaitTimeHelper()
# invalidate old finalized update
@@ -487,39 +534,47 @@ def main() -> None:
# Run the update loop
first_run = True
while True:
- wait_helper.ready_event.clear()
+ requested, request_generation, fast_target = wait_helper.current_request()
# Attempt an update
exception = None
+ fast_requested = requested == UserRequest.FAST
try:
- # TODO: reuse overlay from previous updated instance if it looks clean
- init_overlay()
-
- # ensure we have some params written soon after startup
- updater.set_params(False, update_failed_count, exception)
-
- if not system_time_valid() or first_run:
- first_run = False
- wait_helper.sleep(60)
- continue
-
- update_failed_count += 1
-
- # check for update
- params.put("UpdaterState", "checking...", block=True)
- updater.check_for_update()
-
- # download update
- last_fetch = params.get("UpdaterLastFetchTime")
- timed_out = last_fetch is None or (datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - last_fetch > datetime.timedelta(days=3))
- user_requested_fetch = wait_helper.user_request == UserRequest.FETCH
- if params.get_bool("NetworkMetered") and not timed_out and not user_requested_fetch:
- cloudlog.info("skipping fetch, connection metered")
- elif not download_permitted(params, manual=user_requested_fetch, check_only=wait_helper.user_request == UserRequest.CHECK):
- cloudlog.info("skipping fetch, check-only request or automatic downloads disabled")
+ if fast_requested:
+ update_failed_count += 1
+ params.put("UpdaterState", "updating...", block=True)
+ updater.fast_update(*fast_target)
+ write_time_to_param(params, "UpdaterLastFetchTime")
+ write_time_to_param(params, "LastUpdateTime")
else:
- if updater.fetch_update():
- write_time_to_param(params, "UpdaterLastFetchTime")
+ # TODO: reuse overlay from previous updated instance if it looks clean
+ init_overlay()
+
+ # ensure we have some params written soon after startup
+ updater.set_params(False, update_failed_count, exception)
+
+ if not system_time_valid() or first_run:
+ first_run = False
+ wait_helper.sleep(60)
+ continue
+
+ update_failed_count += 1
+
+ # check for update
+ params.put("UpdaterState", "checking...", block=True)
+ updater.check_for_update()
+
+ # download update
+ last_fetch = params.get("UpdaterLastFetchTime")
+ timed_out = last_fetch is None or (datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - last_fetch > datetime.timedelta(days=3))
+ user_requested_fetch = requested == UserRequest.FETCH
+ if params.get_bool("NetworkMetered") and not timed_out and not user_requested_fetch:
+ cloudlog.info("skipping fetch, connection metered")
+ elif not download_permitted(params, manual=user_requested_fetch, check_only=requested == UserRequest.CHECK):
+ cloudlog.info("skipping fetch, check-only request or automatic downloads disabled")
+ else:
+ if updater.fetch_update():
+ write_time_to_param(params, "UpdaterLastFetchTime")
update_failed_count = 0
except subprocess.CalledProcessError as e:
cloudlog.event(
@@ -536,14 +591,23 @@ def main() -> None:
OVERLAY_INIT.unlink(missing_ok=True)
try:
- params.put("UpdaterState", "idle", block=True)
update_successful = (update_failed_count == 0)
- updater.set_params(update_successful, update_failed_count, exception)
+ if fast_requested:
+ params.put("UpdateFailedCount", update_failed_count, block=True)
+ if exception is None:
+ params.remove("LastUpdateException")
+ else:
+ params.put("LastUpdateException", exception.encode("utf-8")[:4096].decode("utf-8", errors="ignore"), block=True)
+ params.put("UpdaterState", "updating..." if params.get_bool("DoReboot") else "idle", block=True)
+ else:
+ params.put("UpdaterState", "idle", block=True)
+ updater.set_params(update_successful, update_failed_count, exception)
except Exception:
cloudlog.exception("uncaught updated exception while setting params, shouldn't happen")
# infrequent attempts if we successfully updated recently
- wait_helper.user_request = UserRequest.NONE
+ if not wait_helper.finish_request(request_generation):
+ continue
wait_helper.sleep(5*60 if update_failed_count > 0 else 1.5*60*60)
diff --git a/scripts/laptop_device_build.sh b/scripts/laptop_device_build.sh
index 7df6e07cc4..f010868f0c 100755
--- a/scripts/laptop_device_build.sh
+++ b/scripts/laptop_device_build.sh
@@ -616,6 +616,7 @@ run_larch64_build() {
# QCOM-compiled driving/DM models and both camera warp families.
python3 -m tools.laptop_device_build.package_model_chunks --source "${ROOT_DIR}/openpilot/selfdrive/modeld/models" --destination "${ROOT_DIR}/openpilot/selfdrive/modeld/models"
verify_device_artifacts
+ python3 "${ROOT_DIR}/openpilot/common/prebuilt_manifest.py" "${ROOT_DIR}"
touch "${ROOT_DIR}/prebuilt"
}
diff --git a/scripts/native_device_build.sh b/scripts/native_device_build.sh
index 7b4674adf1..6be6a9b6e7 100755
--- a/scripts/native_device_build.sh
+++ b/scripts/native_device_build.sh
@@ -55,6 +55,7 @@ case "${mode}" in
fi
python3 -m tools.laptop_device_build.package_model_chunks --source "${ROOT_DIR}/openpilot/selfdrive/modeld/models" --destination "${ROOT_DIR}/openpilot/selfdrive/modeld/models"
python3 "${ROOT_DIR}/tools/laptop_device_build/validate_artifacts.py" "${ROOT_DIR}"
+ python3 "${ROOT_DIR}/openpilot/common/prebuilt_manifest.py" "${ROOT_DIR}"
touch prebuilt
;;
*)