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
410 lines
15 KiB
Swift
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?()
|
|
}
|
|
}
|
|
}
|