Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

410 lines
15 KiB
Swift

import Foundation
/// Native iOS **background `URLSession`** download for the large on-device
/// model pull (#11841).
///
/// The full-Bun runtime otherwise streams the ~5 GB Eliza-1 weight file to disk
/// with an in-process `fetch()`. iOS suspends that runtime the instant the app
/// backgrounds or the device locks, so the multi-GB transfer stalls at
/// "Loading eliza-1-2B…". A `URLSessionConfiguration.background` download task
/// is owned by the system `nsurlsessiond` daemon, not the app process, so it
/// keeps making progress while the app is suspended, survives a device lock,
/// and can relaunch the app on completion.
///
/// Contract with the JS downloader (`plugin-local-inference`): the downloader
/// drives this bridge synchronously through `host_call` —
/// `bg_download_start`, then it polls `bg_download_status` until the state is
/// terminal, and `bg_download_cancel` on user cancel. The bridge writes the
/// finished file to the exact `destPath` the downloader passed (its `.part`
/// staging path); the downloader then runs its existing sha256 verify + atomic
/// rename into the models directory. Progress and terminal state are reported
/// as plain values so the request/response `host_call` model needs no reverse
/// push channel.
///
/// State survives a process restart (Bun runtime crash/relaunch mid-transfer):
/// active-job metadata (`destPath`/`url`/`total`) and terminal outcomes are
/// persisted to `UserDefaults`, and outstanding background tasks are
/// re-associated by the OS when the session is recreated with the same
/// identifier — the delegate callbacks then rebuild in-memory state from the
/// persisted metadata keyed on `task.taskDescription`.
public final class BackgroundDownloadBridge: NSObject {
/// Process-wide singleton — a background session identifier may only back a
/// single live `URLSession` instance per process.
public static let shared = BackgroundDownloadBridge()
public enum DownloadState: String {
case running
case completed
case failed
case cancelled
}
private static let sessionIdentifier = "ai.eliza.bun.background-download"
private static let persistenceKey = "ai.eliza.bun.background-download.state.v1"
/// Bounded automatic resume attempts for a task that fails with recoverable
/// resume data (transient network drop) before the job is surfaced as
/// failed to the downloader.
private static let maxAutoResumeAttempts = 5
private final class JobState {
let id: String
var destPath: String
var url: String
var headers: [String: String]
var received: Int64
var total: Int64
var state: DownloadState
var error: String?
var task: URLSessionDownloadTask?
var resumeData: Data?
var autoResumeAttempts: Int
init(
id: String,
destPath: String,
url: String,
headers: [String: String],
total: Int64
) {
self.id = id
self.destPath = destPath
self.url = url
self.headers = headers
self.received = 0
self.total = total
self.state = .running
self.error = nil
self.task = nil
self.resumeData = nil
self.autoResumeAttempts = 0
}
}
private let lock = NSLock()
private var jobs: [String: JobState] = [:]
private var backgroundCompletionHandler: (() -> Void)?
private lazy var session: URLSession = {
let config = URLSessionConfiguration.background(
withIdentifier: Self.sessionIdentifier
)
// Relaunch the app in the background when tasks finish while it is
// suspended so the completion handoff runs without user interaction.
config.sessionSendsLaunchEvents = true
// A user-initiated model install must not be deferred by the OS.
config.isDiscretionary = false
config.allowsCellularAccess = true
config.waitsForConnectivity = true
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
override public init() {
super.init()
loadPersistedState()
// Recreate the session eagerly so the OS re-associates any tasks that
// outlived a previous app launch and starts delivering their delegate
// callbacks (progress + completion) into this instance.
_ = session
}
// MARK: - host_call surface
/// Begin (or resume, or re-observe) a background download. Idempotent for a
/// given `id`: a second call while the task is live returns the current
/// snapshot instead of starting a duplicate transfer.
public func start(
id: String,
urlString: String,
headers: [String: String],
destPath: String,
expectedTotalBytes: Int64
) -> [String: Any] {
lock.lock()
defer { lock.unlock() }
if let existing = jobs[id] {
switch existing.state {
case .running, .completed:
return snapshot(existing)
case .failed, .cancelled:
// A prior terminal outcome for the same id is being retried —
// fall through to (re)start, resuming from resume data if we
// captured any.
existing.state = .running
existing.error = nil
existing.destPath = destPath
existing.url = urlString
existing.headers = headers
if expectedTotalBytes > 0 { existing.total = expectedTotalBytes }
existing.autoResumeAttempts = 0
startTask(for: existing)
persistLocked()
return snapshot(existing)
}
}
let job = JobState(
id: id,
destPath: destPath,
url: urlString,
headers: headers,
total: expectedTotalBytes
)
jobs[id] = job
startTask(for: job)
persistLocked()
return snapshot(job)
}
public func status(id: String) -> [String: Any] {
lock.lock()
defer { lock.unlock() }
guard let job = jobs[id] else {
return ["state": DownloadState.failed.rawValue, "error": "unknown download id \(id)"]
}
return snapshot(job)
}
public func cancel(id: String) -> [String: Any] {
lock.lock()
defer { lock.unlock() }
guard let job = jobs[id] else {
return ["state": DownloadState.cancelled.rawValue, "cancelled": false]
}
job.task?.cancel()
job.task = nil
job.resumeData = nil
job.state = .cancelled
persistLocked()
var result = snapshot(job)
result["cancelled"] = true
return result
}
/// AppDelegate relaunch hook: `application(_:handleEventsForBackgroundURLSession:completionHandler:)`
/// forwards here so the completion handler is called once every queued
/// delegate event has been delivered (`urlSessionDidFinishEvents`).
public func handleEventsForBackgroundURLSession(
identifier: String,
completionHandler: @escaping () -> Void
) {
guard identifier == Self.sessionIdentifier else {
completionHandler()
return
}
lock.lock()
backgroundCompletionHandler = completionHandler
lock.unlock()
// Touch the session so it re-associates outstanding tasks and flushes
// their pending delegate callbacks.
_ = session
}
// MARK: - task lifecycle (caller holds `lock`)
private func startTask(for job: JobState) {
let task: URLSessionDownloadTask
if let resumeData = job.resumeData {
task = session.downloadTask(withResumeData: resumeData)
job.resumeData = nil
} else {
guard let url = URL(string: job.url) else {
job.state = .failed
job.error = "invalid download url"
return
}
var request = URLRequest(url: url)
for (key, value) in job.headers {
request.setValue(value, forHTTPHeaderField: key)
}
task = session.downloadTask(with: request)
}
task.taskDescription = job.id
job.task = task
job.state = .running
task.resume()
}
private func job(forTaskDescription description: String?) -> JobState? {
guard let description else { return nil }
return jobs[description]
}
private func snapshot(_ job: JobState) -> [String: Any] {
var result: [String: Any] = [
"id": job.id,
"state": job.state.rawValue,
"received": NSNumber(value: job.received),
"total": NSNumber(value: job.total),
"destPath": job.destPath,
]
if let error = job.error {
result["error"] = error
}
return result
}
// MARK: - persistence
private func persistLocked() {
var payload: [String: [String: Any]] = [:]
for (id, job) in jobs {
var entry: [String: Any] = [
"destPath": job.destPath,
"url": job.url,
"headers": job.headers,
"received": NSNumber(value: job.received),
"total": NSNumber(value: job.total),
"state": job.state.rawValue,
]
if let error = job.error { entry["error"] = error }
payload[id] = entry
}
UserDefaults.standard.set(payload, forKey: Self.persistenceKey)
}
private func loadPersistedState() {
guard
let payload = UserDefaults.standard.dictionary(forKey: Self.persistenceKey)
as? [String: [String: Any]]
else { return }
lock.lock()
defer { lock.unlock() }
for (id, raw) in payload {
guard
let destPath = raw["destPath"] as? String,
let url = raw["url"] as? String
else { continue }
let headers = raw["headers"] as? [String: String] ?? [:]
let total = (raw["total"] as? NSNumber)?.int64Value ?? 0
let job = JobState(
id: id,
destPath: destPath,
url: url,
headers: headers,
total: total
)
job.received = (raw["received"] as? NSNumber)?.int64Value ?? 0
job.state = (raw["state"] as? String).flatMap(DownloadState.init) ?? .running
job.error = raw["error"] as? String
jobs[id] = job
}
}
}
extension BackgroundDownloadBridge: URLSessionDownloadDelegate {
public func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64
) {
lock.lock()
defer { lock.unlock() }
guard let job = job(forTaskDescription: downloadTask.taskDescription) else { return }
job.task = downloadTask
job.received = totalBytesWritten
if totalBytesExpectedToWrite > 0 {
job.total = totalBytesExpectedToWrite
}
job.state = .running
}
public func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {
// The temp file at `location` is deleted the moment this callback
// returns, so the move to the destination must happen synchronously
// here — before we hand control back to URLSession.
lock.lock()
let job = job(forTaskDescription: downloadTask.taskDescription)
lock.unlock()
guard let job else { return }
let fileManager = FileManager.default
let destURL = URL(fileURLWithPath: job.destPath)
do {
try fileManager.createDirectory(
at: destURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
if fileManager.fileExists(atPath: destURL.path) {
try fileManager.removeItem(at: destURL)
}
try fileManager.moveItem(at: location, to: destURL)
} catch {
lock.lock()
job.state = .failed
job.error = "failed to stage downloaded file: \(error.localizedDescription)"
persistLocked()
lock.unlock()
return
}
lock.lock()
if let attributes = try? fileManager.attributesOfItem(atPath: destURL.path),
let size = attributes[.size] as? NSNumber {
job.received = size.int64Value
}
job.state = .completed
job.task = nil
job.error = nil
persistLocked()
lock.unlock()
}
public func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?
) {
lock.lock()
defer { lock.unlock() }
guard let job = job(forTaskDescription: task.taskDescription) else { return }
// A successful transfer already flipped state to `.completed` in
// `didFinishDownloadingTo`; nothing to do here.
if job.state == .completed { return }
guard let error else { return }
let nsError = error as NSError
if nsError.code == NSURLErrorCancelled {
// An explicit cancel already set `.cancelled`; leave it.
if job.state != .cancelled { job.state = .cancelled }
job.task = nil
persistLocked()
return
}
// Recoverable failure: retry from resume data a bounded number of times
// so a transient drop mid-transfer does not surface as a hard failure.
if let resumeData = nsError.userInfo[NSURLSessionDownloadTaskResumeData] as? Data,
job.autoResumeAttempts < Self.maxAutoResumeAttempts {
job.resumeData = resumeData
job.autoResumeAttempts += 1
startTask(for: job)
persistLocked()
return
}
job.state = .failed
job.error = error.localizedDescription
job.task = nil
// Preserve any resume data for a later explicit retry via `start`.
job.resumeData = nsError.userInfo[NSURLSessionDownloadTaskResumeData] as? Data
persistLocked()
}
public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
lock.lock()
let handler = backgroundCompletionHandler
backgroundCompletionHandler = nil
lock.unlock()
DispatchQueue.main.async {
handler?()
}
}
}