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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions plugins/plugin-capacitor-bridge/src/ios/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,7 @@ async function startIosBridgeBackend(): Promise<IosBridgeBackend> {

const runtime = await bootRuntimeWithRetry(bootElizaRuntime);
installIosNativeLlamaHandlers(runtime);
installKeepAwakeBridge();

maybeAutoRunModelGrind();

Expand Down Expand Up @@ -1960,6 +1961,30 @@ function makeIosNativeGenerateHandler(slot: string): GenerateTextHandler {
};
}

/**
* Expose `__ELIZA_BRIDGE__.keep_awake_set` on the full-Bun engine's Bun global
* so the in-process model downloader can hold the iOS idle timer open for the
* duration of a multi-GB transfer (#11841). The native handler (`keep_awake_set`
* in FullBunEngineHost) reference-counts the hold and toggles
* `UIApplication.shared.isIdleTimerDisabled`. Fire-and-forget: the downloader
* calls this synchronously and ignores the result, so a host-call failure can
* never affect the download. Only defines the function when the engine has not
* already installed one.
*/
function installKeepAwakeBridge(): void {
const g = globalThis as typeof globalThis & {
__ELIZA_BRIDGE__?: Record<string, unknown>;
};
g.__ELIZA_BRIDGE__ = g.__ELIZA_BRIDGE__ ?? {};
if (typeof g.__ELIZA_BRIDGE__.keep_awake_set === "function") return;
g.__ELIZA_BRIDGE__.keep_awake_set = (enabled: unknown): boolean => {
void callIosHost("keep_awake_set", { enabled: Boolean(enabled) }).catch(
() => undefined,
);
return true;
};
}

function installIosNativeLlamaHandlers(runtime: IAgentRuntime): void {
const flagged = runtime as IAgentRuntime & {
__iosNativeLlamaHandlersInstalled?: boolean;
Expand Down
138 changes: 138 additions & 0 deletions plugins/plugin-local-inference/src/services/downloader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1238,3 +1238,141 @@ describe("local inference downloader stale-content robustness", () => {
expect(fs.existsSync(`${textPart}.expected`)).toBe(false);
});
});

describe("local inference downloader keep-awake (idle-timer) wiring (#11841)", () => {
type KeepAwakeGlobal = {
__ELIZA_BRIDGE__?: { keep_awake_set?: (on: boolean) => unknown };
};

/**
* Stand in for the native `keep_awake_set` bridge the iOS/Android runtime
* installs on `globalThis.__ELIZA_BRIDGE__`. Records every acquire/release
* edge and restores whatever bridge (usually none, in node) was there before.
*/
function installKeepAwakeSpy(impl?: (on: boolean) => void): {
calls: boolean[];
restore: () => void;
} {
const calls: boolean[] = [];
const g = globalThis as KeepAwakeGlobal;
const hadBridge = "__ELIZA_BRIDGE__" in g;
const priorBridge = g.__ELIZA_BRIDGE__;
g.__ELIZA_BRIDGE__ = {
...(priorBridge ?? {}),
keep_awake_set: (on: boolean) => {
calls.push(on);
impl?.(on);
},
};
return {
calls,
restore: () => {
if (hadBridge) g.__ELIZA_BRIDGE__ = priorBridge;
else delete g.__ELIZA_BRIDGE__;
},
};
}

/**
* `start()` fires `runJob` background (`void this.runJob(...)`) and the job
* emits its terminal (`completed`/`failed`) event from inside its own body —
* the keep-awake release runs one tick later in the `finally`. Wait
* (bounded) for every acquire to be matched by a release before asserting.
*/
async function settleReleases(calls: boolean[]): Promise<void> {
for (let i = 0; i < 200; i++) {
const acquires = calls.filter((on) => on).length;
const releases = calls.filter((on) => !on).length;
if (acquires > 0 && acquires === releases) return;
await new Promise((resolve) => setTimeout(resolve, 5));
}
}

it("holds the screen awake for the transfer and releases it once the job completes", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "eliza-download-test-"));
process.env.ELIZA_STATE_DIR = root;
const model = findCatalogModel("eliza-1-2b");
if (!model) throw new Error("missing test catalog model");
installFetchFixture(freshBundleFixtureFiles());
const spy = installKeepAwakeSpy();
try {
const downloader = new Downloader({
probeDeviceCaps: async () => cpuOnlyCaps,
});
const completed = waitForTerminal(downloader, model.id);
await downloader.start(model.id);
const job = await completed;
await settleReleases(spy.calls);

expect(job.state).toBe("completed");
// The idle timer was disabled for the transfer and re-enabled after.
expect(spy.calls).toContain(true);
expect(spy.calls.at(-1)).toBe(false);
// Every acquire is matched by a release — the finally never leaks a hold.
const acquires = spy.calls.filter((on) => on).length;
const releases = spy.calls.filter((on) => !on).length;
expect(acquires).toBe(releases);
} finally {
spy.restore();
}
});

it("releases the screen-awake hold even when the download fails mid-transfer", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "eliza-download-test-"));
process.env.ELIZA_STATE_DIR = root;
const model = findCatalogModel("eliza-1-2b");
if (!model) throw new Error("missing test catalog model");
// Serve the manifest but 404 the text weight so the job fails after the
// keep-awake hold has already been acquired.
const files = freshBundleFixtureFiles();
files.delete(eliza1BundleRemotePath("text/eliza-1-2b-128k.gguf"));
installFetchFixture(files);
const spy = installKeepAwakeSpy();
try {
const downloader = new Downloader({
probeDeviceCaps: async () => cpuOnlyCaps,
});
const completed = waitForTerminal(downloader, model.id);
await downloader.start(model.id);
await expect(completed).rejects.toThrow();
await settleReleases(spy.calls);

expect(spy.calls).toContain(true);
expect(spy.calls.at(-1)).toBe(false);
const acquires = spy.calls.filter((on) => on).length;
const releases = spy.calls.filter((on) => !on).length;
expect(acquires).toBe(releases);
} finally {
spy.restore();
}
});

it("never lets a throwing keep-awake bridge break the download", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "eliza-download-test-"));
process.env.ELIZA_STATE_DIR = root;
const model = findCatalogModel("eliza-1-2b");
if (!model) throw new Error("missing test catalog model");
installFetchFixture(freshBundleFixtureFiles());
const spy = installKeepAwakeSpy(() => {
throw new Error("bridge exploded");
});
try {
const downloader = new Downloader({
probeDeviceCaps: async () => cpuOnlyCaps,
});
const completed = waitForTerminal(downloader, model.id);
await downloader.start(model.id);
const job = await completed;
await settleReleases(spy.calls);

// The download still succeeds — a keep-awake failure is swallowed and
// must never take the transfer down with it.
expect(job.state).toBe("completed");
// Both edges were still attempted despite each throwing.
expect(spy.calls).toContain(true);
expect(spy.calls).toContain(false);
} finally {
spy.restore();
}
});
});
28 changes: 28 additions & 0 deletions plugins/plugin-local-inference/src/services/downloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,12 +613,39 @@ export class Downloader {
}
}

/**
* On iOS the on-device runtime streams the model download in-process; if the
* device auto-locks, the runtime is suspended and the multi-GB transfer
* stalls at "Loading eliza-1-2B…" (#11841). While a download is active, ask
* the host app to hold the iOS idle timer open via the native
* `keep_awake_set` host function on the `__ELIZA_BRIDGE__` compatibility
* bridge (reference-counted natively so overlapping downloads compose).
* This removes the common auto-lock stall on the JSContext (sideload/dev)
* path; a *manual* lock or backgrounding still needs the native background
* `URLSession` download (the tracked #11841 primary fix). The bridge global
* — and this host function — are absent on every other platform and on the
* full-Bun engine, so this is a safe unconditional no-op there.
*/
private setDownloadKeepAwake(active: boolean): void {
try {
const bridge = (
globalThis as {
__ELIZA_BRIDGE__?: { keep_awake_set?: (on: boolean) => unknown };
}
).__ELIZA_BRIDGE__;
bridge?.keep_awake_set?.(active);
} catch {
// Best-effort screen-wake hint; never let it affect the download.
}
}

private async runJob(
catalogEntry: CatalogModel,
record: ActiveJob,
): Promise<void> {
try {
this.updateState(record, "downloading");
this.setDownloadKeepAwake(true);
await this.assertDiskSpaceForJob(record);
if (catalogEntry.bundleManifestFile) {
await this.runBundleJob(catalogEntry, record);
Expand Down Expand Up @@ -761,6 +788,7 @@ export class Downloader {
this.emit({ type: "failed", job: { ...record.job } });
}
} finally {
this.setDownloadKeepAwake(false);
this.active.delete(record.job.modelId);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ source_files = if include_full_bun_engine
'ios/Sources/ElizaBunRuntimePlugin/ElizaBunRuntime.swift',
'ios/Sources/ElizaBunRuntimePlugin/FullBunEngineHost.swift',
'ios/Sources/ElizaBunRuntimePlugin/SandboxPaths.swift',
'ios/Sources/ElizaBunRuntimePlugin/bridge/KeepAwakeBridge.swift',
'ios/Sources/ElizaBunRuntimePlugin/bridge/LlamaBridgeImpl.swift',
'ios/Sources/ElizaBunRuntimePlugin/kokoro/**/*.swift'
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public struct BridgeKit {
public let log: LogBridge
public let process: ProcessBridge
public let ui: UIBridge
public let keepAwake: KeepAwakeBridge
}

public enum BridgeInstaller {
Expand Down Expand Up @@ -69,6 +70,9 @@ public enum BridgeInstaller {
let ui = UIBridge(plugin: plugin.value)
ui.install(into: ctx)

let keepAwake = KeepAwakeBridge()
keepAwake.install(into: ctx)

return BridgeKit(
fs: fs,
paths: pathsBridge,
Expand All @@ -78,7 +82,8 @@ public enum BridgeInstaller {
llama: llama,
log: log,
process: process,
ui: ui
ui: ui,
keepAwake: keepAwake
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,13 @@ final class FullBunEngineHost {
return handleTtsSynthesize(payload)
case "eliza_asr_transcribe":
return handleAsrTranscribe(payload)
case "keep_awake_set":
// Hold the iOS idle timer open while an in-process model
// download is active so auto-lock cannot suspend the runtime
// mid-transfer (#11841). Reference-counted natively.
let enabled = boolValue(payload, "enabled") ?? false
KeepAwakeBridge.shared.setEnabled(enabled)
return encodeHostEnvelope(ok: true, result: ["enabled": NSNumber(value: enabled)])
default:
return encodeHostEnvelope(
ok: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation
import JavaScriptCore

/// JSContext (compatibility engine) installer for `keep_awake_set`.
///
/// Kept in its own file — separate from the JavaScriptCore-free
/// `KeepAwakeBridge` core — so the core type can compile into the full-Bun
/// engine build (which omits JavaScriptCore and never lists this file in its
/// podspec source set). On the compat path the closure funnels into the same
/// shared ref-counted holder used by the full-Bun `host_call` dispatch.
extension KeepAwakeBridge {
public func install(into ctx: JSContext) {
ctx.installBridgeFunction(name: "keep_awake_set") { args in
let enabled = args.first?.toBool() ?? false
self.setEnabled(enabled)
return true
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Foundation
import JavaScriptCore
#if canImport(UIKit)
import UIKit
#endif

/// Implements the `keep_awake_set(enabled)` host function on the
/// `__ELIZA_BRIDGE__` compatibility (JSContext) bridge.
///
/// While a large on-device model download (or load) is in flight, the agent
/// asks the app to disable the iOS idle timer so the screen does not auto-lock
/// and suspend the embedded runtime mid-transfer. That suspension is what
/// otherwise stalls the ~5 GB Eliza-1 model download at "Loading eliza-1-2B…"
/// (#11841): the download streams through the in-process runtime, which iOS
/// freezes the instant the device auto-locks.
///
/// Reference-counted so overlapping holders (e.g. a text + a vision download)
/// compose; the idle timer is only re-enabled once the last holder clears.
/// This does NOT cover a *manual* lock / backgrounding — that needs the native
/// background `URLSession` download (#11841 primary fix) — but it removes the
/// far more common auto-lock stall for a foregrounded download.
public final class KeepAwakeBridge {
/// Process-wide holder shared by both runtime engines. A device runs exactly
/// one engine (full-Bun *or* JSContext compat), but sharing one ref-counted
/// holder keeps the idle-timer state single-sourced either way.
public static let shared = KeepAwakeBridge()

private let lock = NSLock()
private var holders = 0

public init() {}

public func install(into ctx: JSContext) {
ctx.installBridgeFunction(name: "keep_awake_set") { args in
let enabled = args.first?.toBool() ?? false
self.setHolder(enabled)
return true
}
}

/// Acquire (`true`) or release (`false`) an idle-timer hold. This is the
/// entry point the full-Bun `host_call` dispatch (`FullBunEngineHost`) uses,
/// mirroring the JSContext `keep_awake_set` closure above.
public func setEnabled(_ enabled: Bool) {
setHolder(enabled)
}

/// Force-release the idle-timer hold (call when the runtime tears down so a
/// stuck holder never pins the screen awake past the runtime's lifetime).
public func reset() {
lock.lock()
holders = 0
lock.unlock()
applyIdleTimer(disabled: false)
}

private func setHolder(_ enabled: Bool) {
lock.lock()
holders = max(0, holders + (enabled ? 1 : -1))
let disabled = holders > 0
lock.unlock()
applyIdleTimer(disabled: disabled)
}

private func applyIdleTimer(disabled: Bool) {
#if canImport(UIKit)
DispatchQueue.main.async {
UIApplication.shared.isIdleTimerDisabled = disabled
}
#endif
}
}
Loading