feat: make QuickJS the default plugin engine everywhere (#2775)
Benchmark (test-target, both engines), QuickJS default on all platforms with CODEXBAR_PLUGIN_ENGINE=jsc rollback (env + Debug pane), dedicated 4 MiB worker threads with true-interrupt watchdog, native stack headroom for the overflow guard, CI crash-report collection, and the overflow regression pinned to production stack geometry.
This commit is contained in:
committed by
GitHub
parent
af9bc9382f
commit
f95f252103
@@ -171,6 +171,23 @@ jobs:
|
||||
if: ${{ matrix.shard-index == 0 }}
|
||||
run: ./Scripts/test-plugin-engines.sh
|
||||
|
||||
- name: Collect crash reports on failure
|
||||
if: ${{ failure() }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -uo pipefail
|
||||
reports="$HOME/Library/Logs/DiagnosticReports"
|
||||
found=0
|
||||
if [[ -d "$reports" ]]; then
|
||||
while IFS= read -r file; do
|
||||
found=1
|
||||
echo "::group::crash report $(basename "$file")"
|
||||
cat "$file"
|
||||
echo "::endgroup::"
|
||||
done < <(find "$reports" \( -name '*.ips' -o -name '*.crash' \) -newer .git/HEAD -print 2>/dev/null | head -5)
|
||||
fi
|
||||
[[ "$found" == 1 ]] || echo "no fresh crash reports under $reports"
|
||||
|
||||
- name: Summarize macOS shard
|
||||
if: ${{ always() }}
|
||||
shell: bash
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
- Codex: price persisted usage from token classes when reports are read, so a cold rebuild racing the models.dev catalog can no longer permanently bake fallback rates into SQLite (#2772).
|
||||
- OpenRouter: keep optional key-quota enrichment on its one-second production fast join while making degraded results explicit and preventing loaded CI parity runs from mistaking the fallback snapshot for a golden mismatch (fixes #2778).
|
||||
- Codex: the SQLite cost store now writes each save cycle inside one transaction, so a crash or kill mid-save can never leave session rows updated against stale day aggregates — the previous state survives intact, matching the old JSON path's atomic file replace (refs #2760).
|
||||
- Provider plugins: run each QuickJS context on a dedicated 4 MiB-stack thread, refresh stack bounds at every JavaScript entry, and leave 3 MiB of native headroom so deep recursion raises a clean stack-overflow error instead of crashing.
|
||||
>>>>>>> bae013ccb (feat: make QuickJS the default plugin engine everywhere)
|
||||
- Codex: SQLite cost saves no longer rescan every stored row and snapshot per file — baseline counts and file lookups are precomputed once, cutting a large-corpus (1,700+ sessions) save pass from minutes of CPU to seconds (refs #2760).
|
||||
- Codex: restore JSON-cache retention semantics lost in the SQLite cutover — discovery pruning now reaches the scanner's round-tripped payload so deleted files stop resurfacing, the row budget never sacrifices in-window or recently active sessions, and fork-parent protection again drops stale lineage-only parents (refs #2760).
|
||||
- Codex: the SQLite cost store no longer deletes the whole database on transient failures — lock contention from a concurrent CLI/app writer, disk-full, or a constraint violation now preserve history and only genuine corruption or schema drift triggers a rebuild, which is now logged (refs #2760).
|
||||
@@ -26,6 +28,7 @@
|
||||
- Codex: cost history now lives in a single SQLite store — bounded memory at any corpus size, append-linear catch-up, and no more multi-hundred-MB JSON decode on refresh (#2760). Thanks @xx205 for the accumulator design!
|
||||
- CLI: dashboard snapshot identity now defaults to full; use `--identity redacted` to restore redacted emails.
|
||||
- Provider plugins: run the same bundled JavaScript providers and local plugin CLI on Linux through a sandboxed QuickJS engine, removing the cut-over providers' Linux-only Swift twins.
|
||||
- Provider plugins: use QuickJS on every platform with byte-identical output and hard interruption for hung scripts; Apple builds retain JavaScriptCore as an explicit rollback engine.
|
||||
|
||||
### Fixed
|
||||
- CLI: claude-swap accounts with expired or missing credentials now keep their email on the serve dashboard instead of showing a bare slot number.
|
||||
|
||||
@@ -6,8 +6,8 @@ cd "$ROOT_DIR"
|
||||
|
||||
FILTER='ProviderPluginRuntimeTests|ProviderPluginParityTests|ProviderPluginDetailsParityTests|ProviderPluginExtensionParityTests|Sub2APIPluginGoldenTests|UserProviderPluginPortableTests'
|
||||
|
||||
echo "plugin engine A/B: JavaScriptCore"
|
||||
echo "plugin engine A/B: QuickJS default"
|
||||
env -u CODEXBAR_PLUGIN_ENGINE swift test --filter "$FILTER"
|
||||
|
||||
echo "plugin engine A/B: QuickJS"
|
||||
CODEXBAR_PLUGIN_ENGINE=quickjs swift test --skip-build --filter "$FILTER"
|
||||
echo "plugin engine A/B: JavaScriptCore rollback"
|
||||
CODEXBAR_PLUGIN_ENGINE=jsc swift test --skip-build --filter "$FILTER"
|
||||
|
||||
@@ -7,6 +7,8 @@ struct DebugPane: View {
|
||||
@Bindable var settings: SettingsStore
|
||||
@Bindable var store: UsageStore
|
||||
@AppStorage("debugFileLoggingEnabled") private var debugFileLoggingEnabled = false
|
||||
@AppStorage(ProviderPluginRuntime.javaScriptCoreRollbackDefaultsKey)
|
||||
private var useJavaScriptCorePluginEngine = false
|
||||
// Provider-specific by design: debug probe, fetch, and error pickers historically start on Codex.
|
||||
@State private var currentLogProvider: UsageProvider = .codex
|
||||
@State private var currentFetchProvider: UsageProvider = .codex
|
||||
@@ -73,6 +75,13 @@ struct DebugPane: View {
|
||||
binding: self.$store.debugForceAnimation)
|
||||
}
|
||||
|
||||
SettingsSection(title: L("Provider Plugins")) {
|
||||
PreferenceToggleRow(
|
||||
title: L("use_javascriptcore_plugin_engine"),
|
||||
subtitle: L("use_javascriptcore_plugin_engine_subtitle"),
|
||||
binding: self.$useJavaScriptCorePluginEngine)
|
||||
}
|
||||
|
||||
SettingsSection(
|
||||
title: L("section_loading_animations"),
|
||||
caption: L("loading_animations_caption"))
|
||||
|
||||
@@ -672,6 +672,8 @@
|
||||
"copyright" = "© 2026 بيتر ستاينبرجر. MIT الرخصة.";
|
||||
|
||||
/* Debug Pane */
|
||||
"use_javascriptcore_plugin_engine" = "استخدام JavaScriptCore لإضافات المزوّد";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "للرجوع المتوافق فقط. أعد تشغيل CodexBar للتطبيق؛ يتجاوز CODEXBAR_PLUGIN_ENGINE هذا الإعداد.";
|
||||
"section_logging" = "قطع الأشجار";
|
||||
"enable_file_logging" = "تمكين تسجيل الملفات";
|
||||
"enable_file_logging_subtitle" = "اكتب السجلات إلى %@ للتصحيح.";
|
||||
|
||||
@@ -650,6 +650,8 @@
|
||||
"copyright" = "© 2026 Peter Steinberger. Llicència MIT.";
|
||||
|
||||
/* Debug Pane */
|
||||
"use_javascriptcore_plugin_engine" = "Utilitza JavaScriptCore per als connectors de proveïdor";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "Només com a reversió de compatibilitat. Reinicieu CodexBar per aplicar-ho; CODEXBAR_PLUGIN_ENGINE substitueix aquest ajust.";
|
||||
"section_logging" = "Registre";
|
||||
"enable_file_logging" = "Activeu el registre en fitxer";
|
||||
"enable_file_logging_subtitle" = "Escriu els registres a %@ per a la depuració.";
|
||||
|
||||
@@ -664,6 +664,8 @@
|
||||
"copyright" = "© 2026 Peter Steinberger. MIT-Lizenz.";
|
||||
|
||||
/* Debug Pane */
|
||||
"use_javascriptcore_plugin_engine" = "JavaScriptCore für Anbieter-Plugins verwenden";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "Nur als Kompatibilitäts-Rollback. CodexBar zum Anwenden neu starten; CODEXBAR_PLUGIN_ENGINE überschreibt diese Einstellung.";
|
||||
"section_logging" = "Protokollierung";
|
||||
"enable_file_logging" = "Aktivieren Sie die Dateiprotokollierung";
|
||||
"enable_file_logging_subtitle" = "Schreiben Sie Protokolle zum Debuggen in %@.";
|
||||
|
||||
@@ -649,6 +649,8 @@
|
||||
"copyright" = "© 2026 Peter Steinberger. MIT License.";
|
||||
|
||||
/* Debug Pane */
|
||||
"use_javascriptcore_plugin_engine" = "Use JavaScriptCore for provider plugins";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "Compatibility rollback only. Restart CodexBar to apply; CODEXBAR_PLUGIN_ENGINE overrides this setting.";
|
||||
"section_logging" = "Logging";
|
||||
"enable_file_logging" = "Enable file logging";
|
||||
"enable_file_logging_subtitle" = "Write logs to %@ for debugging.";
|
||||
|
||||
@@ -659,6 +659,8 @@
|
||||
"copyright" = "© 2026 Peter Steinberger. Licencia MIT.";
|
||||
|
||||
/* Debug Pane */
|
||||
"use_javascriptcore_plugin_engine" = "Usar JavaScriptCore para los complementos de proveedores";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "Solo para reversión de compatibilidad. Reinicia CodexBar para aplicarlo; CODEXBAR_PLUGIN_ENGINE anula este ajuste.";
|
||||
"section_logging" = "Registro";
|
||||
"enable_file_logging" = "Activar registro en archivo";
|
||||
"enable_file_logging_subtitle" = "Escribir registros en %@ para depuración.";
|
||||
|
||||
@@ -672,6 +672,8 @@
|
||||
"copyright" = "© ۲۰۲۶ پیتر استاینبرگر. MIT مجوز.";
|
||||
|
||||
/* Debug Pane */
|
||||
"use_javascriptcore_plugin_engine" = "استفاده از JavaScriptCore برای افزونههای ارائهدهنده";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "فقط برای بازگشت سازگاری. برای اعمال CodexBar را بازراهاندازی کنید؛ CODEXBAR_PLUGIN_ENGINE این تنظیم را لغو میکند.";
|
||||
"section_logging" = "چوب بری";
|
||||
"enable_file_logging" = "فعال سازی ثبت فایل";
|
||||
"enable_file_logging_subtitle" = "لاگ ها را برای %@ برای اشکال زدایی بنویسید.";
|
||||
|
||||
@@ -666,6 +666,8 @@
|
||||
"copyright" = "© 2026 Peter Steinberger. Licence MIT.";
|
||||
|
||||
/* Debug Pane */
|
||||
"use_javascriptcore_plugin_engine" = "Utiliser JavaScriptCore pour les extensions de fournisseurs";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "Repli de compatibilité uniquement. Redémarrez CodexBar pour l’appliquer ; CODEXBAR_PLUGIN_ENGINE remplace ce réglage.";
|
||||
"section_logging" = "Journalisation";
|
||||
"enable_file_logging" = "Activer la journalisation des fichiers";
|
||||
"enable_file_logging_subtitle" = "Écrivez les journaux dans %@ pour le débogage.";
|
||||
|
||||
@@ -645,6 +645,8 @@
|
||||
"copyright" = "© 2026 Peter Steinberger. Licenza MIT.";
|
||||
|
||||
/* Debug Pane */
|
||||
"use_javascriptcore_plugin_engine" = "Usar JavaScriptCore para os complementos de provedores";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "Só como reversión de compatibilidade. Reinicia CodexBar para aplicalo; CODEXBAR_PLUGIN_ENGINE substitúe este axuste.";
|
||||
"section_logging" = "Rexistro";
|
||||
"enable_file_logging" = "Activar o rexistro en ficheiro";
|
||||
"enable_file_logging_subtitle" = "Escribe os rexistros en %@ para depuración.";
|
||||
|
||||
@@ -674,6 +674,8 @@
|
||||
"copyright" = "© 2026 Peter Steinberger. Lisensi MIT.";
|
||||
|
||||
/* Debug Pane */
|
||||
"use_javascriptcore_plugin_engine" = "Gunakan JavaScriptCore untuk plugin penyedia";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "Hanya untuk rollback kompatibilitas. Mulai ulang CodexBar untuk menerapkan; CODEXBAR_PLUGIN_ENGINE mengganti pengaturan ini.";
|
||||
"section_logging" = "Pencatatan";
|
||||
"enable_file_logging" = "Aktifkan pencatatan file";
|
||||
"enable_file_logging_subtitle" = "Tulis log ke %@ untuk debugging.";
|
||||
|
||||
@@ -674,6 +674,8 @@
|
||||
"copyright" = "© 2026 Peter Steinberger. Licenza MIT.";
|
||||
|
||||
/* Debug Pane */
|
||||
"use_javascriptcore_plugin_engine" = "Usa JavaScriptCore per i plugin dei provider";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "Solo ripristino di compatibilità. Riavvia CodexBar per applicarlo; CODEXBAR_PLUGIN_ENGINE sostituisce questa impostazione.";
|
||||
"section_logging" = "Log";
|
||||
"enable_file_logging" = "Abilita log su file";
|
||||
"enable_file_logging_subtitle" = "Scrive i log in %@ per il debug.";
|
||||
|
||||
@@ -663,6 +663,8 @@
|
||||
"copyright" = "© 2026 Peter Steinberger. MIT License.";
|
||||
|
||||
/* Debug Pane */
|
||||
"use_javascriptcore_plugin_engine" = "プロバイダープラグインにJavaScriptCoreを使用";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "互換性のためのロールバック専用です。適用するにはCodexBarを再起動してください。CODEXBAR_PLUGIN_ENGINEがこの設定より優先されます。";
|
||||
"section_logging" = "ログ";
|
||||
"enable_file_logging" = "ファイルログを有効にする";
|
||||
"enable_file_logging_subtitle" = "デバッグ用に %@ へログを書き込みます。";
|
||||
|
||||
@@ -649,6 +649,8 @@
|
||||
"check_for_updates" = "업데이트 확인…";
|
||||
"updates_unavailable" = "이 빌드에서는 업데이트를 사용할 수 없습니다.";
|
||||
"copyright" = "© 2026 Peter Steinberger. MIT License.";
|
||||
"use_javascriptcore_plugin_engine" = "제공자 플러그인에 JavaScriptCore 사용";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "호환성 롤백 전용입니다. 적용하려면 CodexBar를 재시작하세요. CODEXBAR_PLUGIN_ENGINE이 이 설정보다 우선합니다.";
|
||||
"section_logging" = "로깅";
|
||||
"enable_file_logging" = "파일 로깅 사용";
|
||||
"enable_file_logging_subtitle" = "디버깅을 위해 %@에 로그를 기록합니다.";
|
||||
|
||||
@@ -666,6 +666,8 @@
|
||||
"copyright" = "© 2026 Peter Steinberger. MIT-licentie.";
|
||||
|
||||
/* Debug Pane */
|
||||
"use_javascriptcore_plugin_engine" = "JavaScriptCore gebruiken voor providerplug-ins";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "Alleen als compatibiliteitsterugval. Start CodexBar opnieuw; CODEXBAR_PLUGIN_ENGINE overschrijft deze instelling.";
|
||||
"section_logging" = "Loggen";
|
||||
"enable_file_logging" = "Bestandsregistratie inschakelen";
|
||||
"enable_file_logging_subtitle" = "Schrijf logboeken naar %@ voor foutopsporing.";
|
||||
|
||||
@@ -674,6 +674,8 @@
|
||||
"copyright" = "© 2026 Peter Steinberger. Licencja MIT.";
|
||||
|
||||
/* Debug Pane */
|
||||
"use_javascriptcore_plugin_engine" = "Używaj JavaScriptCore dla wtyczek dostawców";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "Tylko jako awaryjny tryb zgodności. Uruchom ponownie CodexBar; CODEXBAR_PLUGIN_ENGINE zastępuje to ustawienie.";
|
||||
"section_logging" = "Logowanie";
|
||||
"enable_file_logging" = "Włącz logowanie do pliku";
|
||||
"enable_file_logging_subtitle" = "Zapisuj logi do %@ na potrzeby debugowania.";
|
||||
|
||||
@@ -663,6 +663,8 @@
|
||||
"copyright" = "© 2026 Peter Steinberger. Licença MIT.";
|
||||
|
||||
/* Debug Pane */
|
||||
"use_javascriptcore_plugin_engine" = "Usar JavaScriptCore para plugins de provedores";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "Apenas reversão de compatibilidade. Reinicie o CodexBar para aplicar; CODEXBAR_PLUGIN_ENGINE substitui este ajuste.";
|
||||
"section_logging" = "Logs";
|
||||
"enable_file_logging" = "Ativar logs em arquivo";
|
||||
"enable_file_logging_subtitle" = "Grava logs em %@ para depuração.";
|
||||
|
||||
@@ -667,6 +667,8 @@
|
||||
"copyright" = "© 2026 Peter Steinberger. Лицензия MIT.";
|
||||
|
||||
/* Debug Pane */
|
||||
"use_javascriptcore_plugin_engine" = "Использовать JavaScriptCore для плагинов провайдеров";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "Только для отката совместимости. Перезапустите CodexBar; CODEXBAR_PLUGIN_ENGINE переопределяет эту настройку.";
|
||||
"section_logging" = "Журналирование";
|
||||
"enable_file_logging" = "Включить запись логов";
|
||||
"enable_file_logging_subtitle" = "Записывать логи в %@ для отладки.";
|
||||
|
||||
@@ -665,6 +665,8 @@
|
||||
"copyright" = "© 2026 Peter Steinberger. MIT-licens.";
|
||||
|
||||
/* Debug Pane */
|
||||
"use_javascriptcore_plugin_engine" = "Använd JavaScriptCore för leverantörsinsticksfiler";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "Endast kompatibilitetsåterställning. Starta om CodexBar för att tillämpa; CODEXBAR_PLUGIN_ENGINE åsidosätter inställningen.";
|
||||
"section_logging" = "Loggning";
|
||||
"enable_file_logging" = "Aktivera filloggning";
|
||||
"enable_file_logging_subtitle" = "Skriv loggar till %@ för felsökning.";
|
||||
|
||||
@@ -672,6 +672,8 @@
|
||||
"copyright" = "© 2026 ปีเตอร์ สไตน์เบอร์เกอร์ ใบอนุญาต MIT";
|
||||
|
||||
/* Debug Pane */
|
||||
"use_javascriptcore_plugin_engine" = "ใช้ JavaScriptCore สำหรับปลั๊กอินผู้ให้บริการ";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "ใช้เพื่อย้อนกลับด้านความเข้ากันได้เท่านั้น เริ่ม CodexBar ใหม่เพื่อใช้ค่า; CODEXBAR_PLUGIN_ENGINE จะแทนที่การตั้งค่านี้";
|
||||
"section_logging" = "การบันทึก";
|
||||
"enable_file_logging" = "เปิดใช้งานการบันทึกไฟล์";
|
||||
"enable_file_logging_subtitle" = "เขียนบันทึกไปยัง %@ เพื่อแก้ไขข้อบกพร่อง";
|
||||
|
||||
@@ -672,6 +672,8 @@
|
||||
"copyright" = "© 2026 Peter Steinberger. MIT Lisansı.";
|
||||
|
||||
/* Debug Pane */
|
||||
"use_javascriptcore_plugin_engine" = "Sağlayıcı eklentileri için JavaScriptCore kullan";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "Yalnızca uyumluluk geri dönüşü. Uygulamak için CodexBar’ı yeniden başlatın; CODEXBAR_PLUGIN_ENGINE bu ayarı geçersiz kılar.";
|
||||
"section_logging" = "Günlükleme";
|
||||
"enable_file_logging" = "Dosya günlüğünü etkinleştir";
|
||||
"enable_file_logging_subtitle" = "Hata ayıklama için günlükleri %@ konumuna yaz.";
|
||||
|
||||
@@ -666,6 +666,8 @@
|
||||
"copyright" = "© 2026 Пітер Штайнбергер. Ліцензія MIT.";
|
||||
|
||||
/* Debug Pane */
|
||||
"use_javascriptcore_plugin_engine" = "Використовувати JavaScriptCore для плагінів постачальників";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "Лише для відкату сумісності. Перезапустіть CodexBar; CODEXBAR_PLUGIN_ENGINE перевизначає це налаштування.";
|
||||
"section_logging" = "Лісозаготівля";
|
||||
"enable_file_logging" = "Увімкнути журналювання файлів";
|
||||
"enable_file_logging_subtitle" = "Записати журнали до %@ для налагодження.";
|
||||
|
||||
@@ -662,6 +662,8 @@
|
||||
"copyright" = "© 2026 Peter Steinberger. Giấy phép MIT.";
|
||||
|
||||
/* Debug Pane */
|
||||
"use_javascriptcore_plugin_engine" = "Dùng JavaScriptCore cho phần bổ trợ nhà cung cấp";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "Chỉ dùng để quay lui tương thích. Khởi động lại CodexBar để áp dụng; CODEXBAR_PLUGIN_ENGINE ghi đè cài đặt này.";
|
||||
"section_logging" = "Ghi nhật ký";
|
||||
"enable_file_logging" = "Cho phép ghi nhật ký tệp";
|
||||
"enable_file_logging_subtitle" = "Ghi nhật ký vào %@ để gỡ lỗi.";
|
||||
|
||||
@@ -639,6 +639,8 @@
|
||||
"check_for_updates" = "检查更新…";
|
||||
"updates_unavailable" = "此构建中更新不可用。";
|
||||
"copyright" = "© 2026 Peter Steinberger。MIT 许可证。";
|
||||
"use_javascriptcore_plugin_engine" = "为提供商插件使用 JavaScriptCore";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "仅用于兼容性回退。重启 CodexBar 后生效;CODEXBAR_PLUGIN_ENGINE 会覆盖此设置。";
|
||||
"section_logging" = "日志";
|
||||
"enable_file_logging" = "启用文件日志";
|
||||
"enable_file_logging_subtitle" = "将日志写入 %@ 以进行调试。";
|
||||
|
||||
@@ -660,6 +660,8 @@
|
||||
"check_for_updates" = "檢查更新…";
|
||||
"updates_unavailable" = "此建置無法使用更新功能。";
|
||||
"copyright" = "© 2026 Peter Steinberger。MIT 許可證。";
|
||||
"use_javascriptcore_plugin_engine" = "為供應商外掛使用 JavaScriptCore";
|
||||
"use_javascriptcore_plugin_engine_subtitle" = "僅用於相容性回復。重新啟動 CodexBar 後生效;CODEXBAR_PLUGIN_ENGINE 會覆寫此設定。";
|
||||
"section_logging" = "記錄";
|
||||
"enable_file_logging" = "啟用檔案記錄";
|
||||
"enable_file_logging_subtitle" = "將記錄寫入 %@ 以進行除錯。";
|
||||
|
||||
@@ -13,6 +13,7 @@ public final class ProviderPluginRuntime: @unchecked Sendable {
|
||||
public static let defaultTimeout: TimeInterval = 20
|
||||
public static let maximumResponseBytes = 5 * 1024 * 1024
|
||||
public static let engineEnvironmentKey = "CODEXBAR_PLUGIN_ENGINE"
|
||||
public static let javaScriptCoreRollbackDefaultsKey = "debugUseJavaScriptCorePluginEngine"
|
||||
|
||||
public let manifest: ProviderPluginManifest
|
||||
|
||||
@@ -230,16 +231,30 @@ public final class ProviderPluginRuntime: @unchecked Sendable {
|
||||
self.lock.unlock()
|
||||
}
|
||||
|
||||
private static func resolveEngineKind(_ requested: ProviderPluginEngineKind) -> ProviderPluginEngineKind {
|
||||
static func resolveEngineKind(_ requested: ProviderPluginEngineKind) -> ProviderPluginEngineKind {
|
||||
self.resolveEngineKind(
|
||||
requested,
|
||||
environment: ProcessInfo.processInfo.environment,
|
||||
useJavaScriptCoreRollback: UserDefaults.standard.bool(forKey: self.javaScriptCoreRollbackDefaultsKey))
|
||||
}
|
||||
|
||||
static func resolveEngineKind(
|
||||
_ requested: ProviderPluginEngineKind,
|
||||
environment: [String: String],
|
||||
useJavaScriptCoreRollback: Bool) -> ProviderPluginEngineKind
|
||||
{
|
||||
guard requested == .automatic else { return requested }
|
||||
if ProcessInfo.processInfo.environment[self.engineEnvironmentKey]?.lowercased() == "quickjs" {
|
||||
return .quickJS
|
||||
}
|
||||
#if canImport(JavaScriptCore)
|
||||
return .javaScriptCore
|
||||
#else
|
||||
return .quickJS
|
||||
switch environment[self.engineEnvironmentKey]?.lowercased() {
|
||||
case "jsc": return .javaScriptCore
|
||||
case "quickjs": return .quickJS
|
||||
default:
|
||||
if useJavaScriptCoreRollback {
|
||||
return .javaScriptCore
|
||||
}
|
||||
}
|
||||
#endif
|
||||
return .quickJS
|
||||
}
|
||||
|
||||
private func redactedError(_ error: Error, secrets: Dictionary<String, String>.Values) -> Error {
|
||||
|
||||
@@ -18,6 +18,11 @@ private enum QuickJSHostFunction: Int32 {
|
||||
case amountFromPercent
|
||||
}
|
||||
|
||||
enum QuickJSRuntimeLimits {
|
||||
/// Leave ample native-stack headroom for Swift entry frames and QuickJS's stack-overflow error construction.
|
||||
static let nativeStackSizeBytes = 4 * 1024 * 1024
|
||||
}
|
||||
|
||||
private func quickJSHostCallback(
|
||||
_ opaque: UnsafeMutableRawPointer?,
|
||||
_ context: OpaquePointer?,
|
||||
@@ -137,82 +142,10 @@ private final class QuickJSPluginValue: ProviderPluginValue {
|
||||
|
||||
final class QuickJSProviderPluginEngine: ProviderPluginEngine, @unchecked Sendable {
|
||||
static let memoryLimitBytes = 64 * 1024 * 1024
|
||||
static let stackLimitBytes = 2 * 1024 * 1024
|
||||
static let stackLimitBytes = 1 * 1024 * 1024
|
||||
|
||||
static func transpileTypeScript(source: String, sucraseSource: String) throws -> String {
|
||||
guard let runtime = JS_NewRuntime() else {
|
||||
throw ProviderPluginError.load("QuickJS could not create a TypeScript transpiler runtime")
|
||||
}
|
||||
JS_SetMemoryLimit(runtime, self.memoryLimitBytes)
|
||||
JS_SetMaxStackSize(runtime, self.stackLimitBytes)
|
||||
guard let context = JS_NewContext(runtime) else {
|
||||
JS_FreeRuntime(runtime)
|
||||
throw ProviderPluginError.load("QuickJS could not create a TypeScript transpiler context")
|
||||
}
|
||||
JS_UpdateStackTop(runtime)
|
||||
let watchdog = cqjs_watchdog_create(nil, nil)
|
||||
if let watchdog {
|
||||
cqjs_watchdog_install(watchdog, runtime, context)
|
||||
cqjs_watchdog_arm(watchdog, UInt64(ProviderPluginRuntime.defaultTimeout * 1000))
|
||||
}
|
||||
defer {
|
||||
if let watchdog {
|
||||
cqjs_watchdog_disarm(watchdog)
|
||||
cqjs_watchdog_destroy(watchdog)
|
||||
}
|
||||
JS_FreeContext(context)
|
||||
JS_FreeRuntime(runtime)
|
||||
}
|
||||
|
||||
func exceptionMessage() -> String {
|
||||
let exception = JS_GetException(context)
|
||||
defer { cqjs_free_value(context, exception) }
|
||||
var length = 0
|
||||
guard let pointer = JS_ToCStringLen2(context, &length, exception, false) else { return "unknown error" }
|
||||
defer { JS_FreeCString(context, pointer) }
|
||||
let bytes = UnsafeRawPointer(pointer).assumingMemoryBound(to: UInt8.self)
|
||||
return String(bytes: UnsafeBufferPointer(start: bytes, count: length), encoding: .utf8) ?? "unknown error"
|
||||
}
|
||||
|
||||
func evaluate(_ script: String, filename: String) throws -> JSValue {
|
||||
let value = script.utf8CString.withUnsafeBufferPointer { scriptBuffer in
|
||||
filename.withCString { filenamePointer in
|
||||
JS_Eval(
|
||||
context,
|
||||
scriptBuffer.baseAddress,
|
||||
scriptBuffer.count - 1,
|
||||
filenamePointer,
|
||||
JS_EVAL_TYPE_GLOBAL)
|
||||
}
|
||||
}
|
||||
guard !cqjs_is_exception(value) else {
|
||||
throw ProviderPluginError.load("TypeScript transpilation failed: \(exceptionMessage())")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
let sucrase = try evaluate(sucraseSource, filename: "sucrase.js")
|
||||
cqjs_free_value(context, sucrase)
|
||||
let global = JS_GetGlobalObject(context)
|
||||
defer { cqjs_free_value(context, global) }
|
||||
let sourceValue = source.utf8CString.withUnsafeBufferPointer { buffer in
|
||||
JS_NewStringLen(context, buffer.baseAddress, buffer.count - 1)
|
||||
}
|
||||
_ = JS_SetPropertyStr(context, global, "__codexbarTypeScriptSource", sourceValue)
|
||||
let result = try evaluate(
|
||||
"sucrase.transform(__codexbarTypeScriptSource, {transforms:['typescript']}).code",
|
||||
filename: "<sucrase-transform>")
|
||||
defer { cqjs_free_value(context, result) }
|
||||
var length = 0
|
||||
guard let pointer = JS_ToCStringLen2(context, &length, result, false) else {
|
||||
throw ProviderPluginError.load("TypeScript transpilation returned no output")
|
||||
}
|
||||
defer { JS_FreeCString(context, pointer) }
|
||||
let bytes = UnsafeRawPointer(pointer).assumingMemoryBound(to: UInt8.self)
|
||||
guard let output = String(bytes: UnsafeBufferPointer(start: bytes, count: length), encoding: .utf8),
|
||||
!output.isEmpty
|
||||
else { throw ProviderPluginError.load("TypeScript transpilation returned no output") }
|
||||
return output
|
||||
try QuickJSTypeScriptTranspiler.transpile(source: source, sucraseSource: sucraseSource)
|
||||
}
|
||||
|
||||
private struct FetchState {
|
||||
@@ -229,7 +162,9 @@ final class QuickJSProviderPluginEngine: ProviderPluginEngine, @unchecked Sendab
|
||||
let expiresAt: Date
|
||||
}
|
||||
|
||||
private let queue: DispatchQueue
|
||||
// @unchecked Sendable is safe because every mutable engine field and QuickJS API call is confined
|
||||
// to this serial worker. requestInterrupt() is the watchdog's explicitly thread-safe escape hatch.
|
||||
private let worker: QuickJSSerialWorker
|
||||
private let runtime: OpaquePointer
|
||||
fileprivate let context: OpaquePointer
|
||||
private let transport: any ProviderHTTPTransport
|
||||
@@ -259,34 +194,37 @@ final class QuickJSProviderPluginEngine: ProviderPluginEngine, @unchecked Sendab
|
||||
timeout: TimeInterval,
|
||||
responseSizeLimit: Int,
|
||||
rejectsNonSuccessResponses: Bool,
|
||||
allowsDynamicID: Bool) throws -> QuickJSProviderPluginEngine
|
||||
allowsDynamicID: Bool,
|
||||
workerStackSizeBytes: Int = QuickJSRuntimeLimits.nativeStackSizeBytes) throws -> QuickJSProviderPluginEngine
|
||||
{
|
||||
guard let runtime = JS_NewRuntime() else {
|
||||
throw ProviderPluginError.load("QuickJS could not create a runtime")
|
||||
}
|
||||
JS_SetMemoryLimit(runtime, Self.memoryLimitBytes)
|
||||
JS_SetMaxStackSize(runtime, Self.stackLimitBytes)
|
||||
guard let context = JS_NewContext(runtime) else {
|
||||
JS_FreeRuntime(runtime)
|
||||
throw ProviderPluginError.load("QuickJS could not create a context")
|
||||
}
|
||||
let queue = DispatchQueue(label: "com.steipete.codexbar.provider-plugin.quickjs.\(UUID().uuidString)")
|
||||
let engine = QuickJSProviderPluginEngine(
|
||||
queue: queue,
|
||||
runtime: runtime,
|
||||
context: context,
|
||||
transport: transport,
|
||||
timeout: timeout,
|
||||
responseSizeLimit: responseSizeLimit,
|
||||
rejectsNonSuccessResponses: rejectsNonSuccessResponses)
|
||||
return try queue.sync {
|
||||
let worker = QuickJSSerialWorker(
|
||||
name: "CodexBar QuickJS provider plugin",
|
||||
stackSizeBytes: workerStackSizeBytes)
|
||||
return try worker.sync {
|
||||
guard let runtime = JS_NewRuntime() else {
|
||||
throw ProviderPluginError.load("QuickJS could not create a runtime")
|
||||
}
|
||||
JS_SetMemoryLimit(runtime, Self.memoryLimitBytes)
|
||||
JS_SetMaxStackSize(runtime, Self.stackLimitBytes)
|
||||
guard let context = JS_NewContext(runtime) else {
|
||||
JS_FreeRuntime(runtime)
|
||||
throw ProviderPluginError.load("QuickJS could not create a context")
|
||||
}
|
||||
let engine = QuickJSProviderPluginEngine(
|
||||
worker: worker,
|
||||
runtime: runtime,
|
||||
context: context,
|
||||
transport: transport,
|
||||
timeout: timeout,
|
||||
responseSizeLimit: responseSizeLimit,
|
||||
rejectsNonSuccessResponses: rejectsNonSuccessResponses)
|
||||
try engine.load(source: source, preludeSource: preludeSource, allowsDynamicID: allowsDynamicID)
|
||||
return engine
|
||||
}
|
||||
}
|
||||
|
||||
private init(
|
||||
queue: DispatchQueue,
|
||||
worker: QuickJSSerialWorker,
|
||||
runtime: OpaquePointer,
|
||||
context: OpaquePointer,
|
||||
transport: any ProviderHTTPTransport,
|
||||
@@ -294,7 +232,7 @@ final class QuickJSProviderPluginEngine: ProviderPluginEngine, @unchecked Sendab
|
||||
responseSizeLimit: Int,
|
||||
rejectsNonSuccessResponses: Bool)
|
||||
{
|
||||
self.queue = queue
|
||||
self.worker = worker
|
||||
self.runtime = runtime
|
||||
self.context = context
|
||||
self.transport = transport
|
||||
@@ -304,21 +242,38 @@ final class QuickJSProviderPluginEngine: ProviderPluginEngine, @unchecked Sendab
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let definition = self.definition {
|
||||
cqjs_free_value(self.context, definition)
|
||||
let runtime = self.runtime
|
||||
let context = self.context
|
||||
let definition = self.definition
|
||||
let applyPrelude = self.applyPrelude
|
||||
let fetchUsage = self.fetchUsage
|
||||
let watchdog = self.watchdog
|
||||
let teardown = {
|
||||
if let definition {
|
||||
cqjs_free_value(context, definition)
|
||||
}
|
||||
if let applyPrelude {
|
||||
cqjs_free_value(context, applyPrelude)
|
||||
}
|
||||
if let fetchUsage {
|
||||
cqjs_free_value(context, fetchUsage)
|
||||
}
|
||||
if let watchdog {
|
||||
cqjs_watchdog_disarm(watchdog)
|
||||
}
|
||||
// The runtime retains the interrupt-handler opaque pointer until it is freed.
|
||||
JS_FreeContext(context)
|
||||
JS_FreeRuntime(runtime)
|
||||
if let watchdog {
|
||||
cqjs_watchdog_destroy(watchdog)
|
||||
}
|
||||
}
|
||||
if let applyPrelude = self.applyPrelude {
|
||||
cqjs_free_value(self.context, applyPrelude)
|
||||
if self.worker.isCurrentThread {
|
||||
teardown()
|
||||
} else {
|
||||
try? self.worker.sync(teardown)
|
||||
}
|
||||
if let fetchUsage = self.fetchUsage {
|
||||
cqjs_free_value(self.context, fetchUsage)
|
||||
}
|
||||
if let watchdog = self.watchdog {
|
||||
cqjs_watchdog_disarm(watchdog)
|
||||
cqjs_watchdog_destroy(watchdog)
|
||||
}
|
||||
JS_FreeContext(self.context)
|
||||
JS_FreeRuntime(self.runtime)
|
||||
self.worker.shutdown()
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
@@ -332,9 +287,9 @@ final class QuickJSProviderPluginEngine: ProviderPluginEngine, @unchecked Sendab
|
||||
instanceCookieResolver: ProviderPluginRuntime.InstanceCookieResolver?,
|
||||
completion: @escaping @Sendable (Result<UsageSnapshot, Error>) -> Void)
|
||||
{
|
||||
self.queue.async {
|
||||
self.worker.async {
|
||||
completion(Result {
|
||||
try self.fetchOnQueue(
|
||||
try self.fetchOnWorker(
|
||||
settings: settings,
|
||||
secrets: secrets,
|
||||
now: now,
|
||||
@@ -347,7 +302,7 @@ final class QuickJSProviderPluginEngine: ProviderPluginEngine, @unchecked Sendab
|
||||
}
|
||||
|
||||
func globalType(of name: String) throws -> String {
|
||||
try self.queue.sync {
|
||||
try self.worker.sync {
|
||||
JS_UpdateStackTop(self.runtime)
|
||||
let escaped = name.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "'", with: "\\'")
|
||||
@@ -404,7 +359,7 @@ final class QuickJSProviderPluginEngine: ProviderPluginEngine, @unchecked Sendab
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
private func fetchOnQueue(
|
||||
private func fetchOnWorker(
|
||||
settings: [String: String],
|
||||
secrets: [String: String],
|
||||
now: Date,
|
||||
@@ -458,6 +413,7 @@ final class QuickJSProviderPluginEngine: ProviderPluginEngine, @unchecked Sendab
|
||||
if JS_IsPromise(result) {
|
||||
while JS_PromiseState(self.context, result) == JS_PROMISE_PENDING {
|
||||
var pendingContext: OpaquePointer?
|
||||
JS_UpdateStackTop(self.runtime)
|
||||
let executed = JS_ExecutePendingJob(self.runtime, &pendingContext)
|
||||
if executed < 0 {
|
||||
cqjs_free_value(self.context, result)
|
||||
@@ -863,6 +819,7 @@ final class QuickJSProviderPluginEngine: ProviderPluginEngine, @unchecked Sendab
|
||||
}
|
||||
|
||||
private func call(_ function: JSValue, arguments: [JSValue]) throws -> JSValue {
|
||||
JS_UpdateStackTop(self.runtime)
|
||||
var mutableArguments = arguments
|
||||
let result = mutableArguments.withUnsafeMutableBufferPointer { buffer in
|
||||
JS_Call(self.context, function, cqjs_undefined(), Int32(buffer.count), buffer.baseAddress)
|
||||
@@ -872,6 +829,7 @@ final class QuickJSProviderPluginEngine: ProviderPluginEngine, @unchecked Sendab
|
||||
}
|
||||
|
||||
private func evaluate(_ source: String, filename: String) throws -> JSValue {
|
||||
JS_UpdateStackTop(self.runtime)
|
||||
let result = source.utf8CString.withUnsafeBufferPointer { sourceBuffer in
|
||||
filename.withCString { filenamePointer in
|
||||
JS_Eval(
|
||||
@@ -987,7 +945,106 @@ final class QuickJSProviderPluginEngine: ProviderPluginEngine, @unchecked Sendab
|
||||
}
|
||||
}
|
||||
|
||||
private final class QuickJSBlockingResult<Value: Sendable>: @unchecked Sendable {
|
||||
private final class QuickJSSerialWorker: @unchecked Sendable {
|
||||
typealias Job = () -> Void
|
||||
|
||||
private final class State: @unchecked Sendable {
|
||||
private let condition = NSCondition()
|
||||
private var jobs: [Job] = []
|
||||
private var acceptsJobs = true
|
||||
private var stopped = false
|
||||
|
||||
func enqueue(_ job: @escaping Job) -> Bool {
|
||||
self.condition.lock()
|
||||
defer { self.condition.unlock() }
|
||||
guard self.acceptsJobs else { return false }
|
||||
self.jobs.append(job)
|
||||
self.condition.signal()
|
||||
return true
|
||||
}
|
||||
|
||||
func next() -> Job? {
|
||||
self.condition.lock()
|
||||
defer { self.condition.unlock() }
|
||||
while self.jobs.isEmpty, self.acceptsJobs {
|
||||
self.condition.wait()
|
||||
}
|
||||
guard !self.jobs.isEmpty else { return nil }
|
||||
return self.jobs.removeFirst()
|
||||
}
|
||||
|
||||
func beginShutdown() {
|
||||
self.condition.lock()
|
||||
self.acceptsJobs = false
|
||||
self.condition.broadcast()
|
||||
self.condition.unlock()
|
||||
}
|
||||
|
||||
func markStopped() {
|
||||
self.condition.lock()
|
||||
self.stopped = true
|
||||
self.condition.broadcast()
|
||||
self.condition.unlock()
|
||||
}
|
||||
|
||||
func waitUntilStopped() {
|
||||
self.condition.lock()
|
||||
while !self.stopped {
|
||||
self.condition.wait()
|
||||
}
|
||||
self.condition.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private let state: State
|
||||
private let thread: Thread
|
||||
|
||||
init(name: String, stackSizeBytes: Int) {
|
||||
let state = State()
|
||||
self.state = state
|
||||
self.thread = Thread {
|
||||
defer { state.markStopped() }
|
||||
while let job = state.next() {
|
||||
job()
|
||||
}
|
||||
}
|
||||
self.thread.name = name
|
||||
self.thread.stackSize = stackSizeBytes
|
||||
self.thread.start()
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.shutdown()
|
||||
}
|
||||
|
||||
var isCurrentThread: Bool {
|
||||
Thread.current === self.thread
|
||||
}
|
||||
|
||||
func async(_ operation: @escaping Job) {
|
||||
precondition(self.state.enqueue(operation), "QuickJS worker accepted work after shutdown")
|
||||
}
|
||||
|
||||
func sync<Value: Sendable>(_ operation: @escaping () throws -> Value) throws -> Value {
|
||||
if self.isCurrentThread {
|
||||
return try operation()
|
||||
}
|
||||
let box = QuickJSBlockingResult<Value>()
|
||||
precondition(self.state.enqueue {
|
||||
box.finish(Result { try operation() })
|
||||
}, "QuickJS worker accepted work after shutdown")
|
||||
return try box.wait().get()
|
||||
}
|
||||
|
||||
func shutdown() {
|
||||
self.state.beginShutdown()
|
||||
if !self.isCurrentThread {
|
||||
self.state.waitUntilStopped()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class QuickJSBlockingResult<Value: Sendable>: @unchecked Sendable {
|
||||
private let condition = NSCondition()
|
||||
private var result: Result<Value, Error>?
|
||||
|
||||
@@ -1006,6 +1063,18 @@ private final class QuickJSBlockingResult<Value: Sendable>: @unchecked Sendable
|
||||
}
|
||||
return self.result
|
||||
}
|
||||
|
||||
func wait() -> Result<Value, Error> {
|
||||
self.condition.lock()
|
||||
defer { self.condition.unlock() }
|
||||
while self.result == nil {
|
||||
self.condition.wait()
|
||||
}
|
||||
guard let result = self.result else {
|
||||
preconditionFailure("QuickJS blocking result signaled without a value")
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private final class QuickJSRedactionValues: @unchecked Sendable {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import CQuickJS
|
||||
import Foundation
|
||||
|
||||
enum QuickJSTypeScriptTranspiler {
|
||||
static func transpile(source: String, sucraseSource: String) throws -> String {
|
||||
let box = QuickJSBlockingResult<String>()
|
||||
let thread = Thread {
|
||||
box.finish(Result {
|
||||
try self.transpileOnCurrentThread(source: source, sucraseSource: sucraseSource)
|
||||
})
|
||||
}
|
||||
// Dispatch/Swift cooperative workers can have less native stack than QuickJS's 2 MiB limit.
|
||||
thread.stackSize = QuickJSRuntimeLimits.nativeStackSizeBytes
|
||||
thread.name = "CodexBar QuickJS TypeScript transpiler"
|
||||
thread.start()
|
||||
|
||||
let deadline = Date().addingTimeInterval(ProviderPluginRuntime.defaultTimeout + 1)
|
||||
while Date() < deadline {
|
||||
if let result = box.wait(until: deadline) {
|
||||
return try result.get()
|
||||
}
|
||||
}
|
||||
throw ProviderPluginError.timedOut
|
||||
}
|
||||
|
||||
private static func transpileOnCurrentThread(source: String, sucraseSource: String) throws -> String {
|
||||
guard let runtime = JS_NewRuntime() else {
|
||||
throw ProviderPluginError.load("QuickJS could not create a TypeScript transpiler runtime")
|
||||
}
|
||||
JS_SetMemoryLimit(runtime, QuickJSProviderPluginEngine.memoryLimitBytes)
|
||||
JS_SetMaxStackSize(runtime, QuickJSProviderPluginEngine.stackLimitBytes)
|
||||
guard let context = JS_NewContext(runtime) else {
|
||||
JS_FreeRuntime(runtime)
|
||||
throw ProviderPluginError.load("QuickJS could not create a TypeScript transpiler context")
|
||||
}
|
||||
JS_UpdateStackTop(runtime)
|
||||
guard let watchdog = cqjs_watchdog_create(nil, nil) else {
|
||||
JS_FreeContext(context)
|
||||
JS_FreeRuntime(runtime)
|
||||
throw ProviderPluginError.load("QuickJS could not create its TypeScript transpiler watchdog")
|
||||
}
|
||||
cqjs_watchdog_install(watchdog, runtime, context)
|
||||
cqjs_watchdog_arm(watchdog, UInt64(ProviderPluginRuntime.defaultTimeout * 1000))
|
||||
defer {
|
||||
cqjs_watchdog_disarm(watchdog)
|
||||
// The runtime retains the interrupt-handler opaque pointer until it is freed.
|
||||
JS_FreeContext(context)
|
||||
JS_FreeRuntime(runtime)
|
||||
cqjs_watchdog_destroy(watchdog)
|
||||
}
|
||||
|
||||
func exceptionMessage() -> String {
|
||||
let exception = JS_GetException(context)
|
||||
defer { cqjs_free_value(context, exception) }
|
||||
var length = 0
|
||||
guard let pointer = JS_ToCStringLen2(context, &length, exception, false) else { return "unknown error" }
|
||||
defer { JS_FreeCString(context, pointer) }
|
||||
let bytes = UnsafeRawPointer(pointer).assumingMemoryBound(to: UInt8.self)
|
||||
return String(bytes: UnsafeBufferPointer(start: bytes, count: length), encoding: .utf8) ?? "unknown error"
|
||||
}
|
||||
|
||||
func evaluate(_ script: String, filename: String) throws -> JSValue {
|
||||
JS_UpdateStackTop(runtime)
|
||||
let value = script.utf8CString.withUnsafeBufferPointer { scriptBuffer in
|
||||
filename.withCString { filenamePointer in
|
||||
JS_Eval(
|
||||
context,
|
||||
scriptBuffer.baseAddress,
|
||||
scriptBuffer.count - 1,
|
||||
filenamePointer,
|
||||
JS_EVAL_TYPE_GLOBAL)
|
||||
}
|
||||
}
|
||||
guard !cqjs_is_exception(value) else {
|
||||
throw ProviderPluginError.load("TypeScript transpilation failed: \(exceptionMessage())")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
let sucrase = try evaluate(sucraseSource, filename: "sucrase.js")
|
||||
cqjs_free_value(context, sucrase)
|
||||
let global = JS_GetGlobalObject(context)
|
||||
defer { cqjs_free_value(context, global) }
|
||||
let sourceValue = source.utf8CString.withUnsafeBufferPointer { buffer in
|
||||
JS_NewStringLen(context, buffer.baseAddress, buffer.count - 1)
|
||||
}
|
||||
_ = JS_SetPropertyStr(context, global, "__codexbarTypeScriptSource", sourceValue)
|
||||
let result = try evaluate(
|
||||
"sucrase.transform(__codexbarTypeScriptSource, {transforms:['typescript']}).code",
|
||||
filename: "<sucrase-transform>")
|
||||
defer { cqjs_free_value(context, result) }
|
||||
var length = 0
|
||||
guard let pointer = JS_ToCStringLen2(context, &length, result, false) else {
|
||||
throw ProviderPluginError.load("TypeScript transpilation returned no output")
|
||||
}
|
||||
defer { JS_FreeCString(context, pointer) }
|
||||
let bytes = UnsafeRawPointer(pointer).assumingMemoryBound(to: UInt8.self)
|
||||
guard let output = String(bytes: UnsafeBufferPointer(start: bytes, count: length), encoding: .utf8),
|
||||
!output.isEmpty
|
||||
else { throw ProviderPluginError.load("TypeScript transpilation returned no output") }
|
||||
return output
|
||||
}
|
||||
}
|
||||
@@ -363,6 +363,22 @@ public final class UserProviderPluginLoader: @unchecked Sendable {
|
||||
throw ProviderPluginError.load("bundled Sucrase \(Self.sucraseVersion) resource was not found")
|
||||
}
|
||||
let sucraseSource = try String(contentsOf: resourceURL, encoding: .utf8)
|
||||
let output = switch ProviderPluginRuntime.resolveEngineKind(.automatic) {
|
||||
case .automatic:
|
||||
preconditionFailure("automatic plugin engine selection must be resolved")
|
||||
case .javaScriptCore:
|
||||
try Self.transpileTypeScriptWithJavaScriptCore(source: source, sucraseSource: sucraseSource)
|
||||
case .quickJS:
|
||||
try QuickJSProviderPluginEngine.transpileTypeScript(source: source, sucraseSource: sucraseSource)
|
||||
}
|
||||
try Data(output.utf8).write(to: cacheURL, options: .atomic)
|
||||
return (output, cacheURL, false)
|
||||
}
|
||||
|
||||
private static func transpileTypeScriptWithJavaScriptCore(
|
||||
source: String,
|
||||
sucraseSource: String) throws -> String
|
||||
{
|
||||
#if canImport(JavaScriptCore)
|
||||
guard let context = JSContext() else {
|
||||
throw ProviderPluginError.load("JavaScriptCore could not create a TypeScript transpiler context")
|
||||
@@ -383,13 +399,10 @@ public final class UserProviderPluginLoader: @unchecked Sendable {
|
||||
guard let output = result?.toString(), !output.isEmpty else {
|
||||
throw ProviderPluginError.load("TypeScript transpilation returned no output")
|
||||
}
|
||||
return output
|
||||
#else
|
||||
let output = try QuickJSProviderPluginEngine.transpileTypeScript(
|
||||
source: source,
|
||||
sucraseSource: sucraseSource)
|
||||
throw ProviderPluginError.load("JavaScriptCore is unavailable on this platform")
|
||||
#endif
|
||||
try Data(output.utf8).write(to: cacheURL, options: .atomic)
|
||||
return (output, cacheURL, false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,40 @@ import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct ProviderPluginRuntimeTests {
|
||||
@Test
|
||||
func `automatic engine defaults to QuickJS`() {
|
||||
#expect(ProviderPluginRuntime.resolveEngineKind(
|
||||
.automatic,
|
||||
environment: [:],
|
||||
useJavaScriptCoreRollback: false) == .quickJS)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `explicit engine selection bypasses automatic policy`() {
|
||||
#expect(ProviderPluginRuntime.resolveEngineKind(
|
||||
.quickJS,
|
||||
environment: [ProviderPluginRuntime.engineEnvironmentKey: "jsc"],
|
||||
useJavaScriptCoreRollback: true) == .quickJS)
|
||||
}
|
||||
|
||||
#if canImport(JavaScriptCore)
|
||||
@Test
|
||||
func `JavaScriptCore rollback supports environment and debug defaults`() {
|
||||
#expect(ProviderPluginRuntime.resolveEngineKind(
|
||||
.automatic,
|
||||
environment: [ProviderPluginRuntime.engineEnvironmentKey: "jsc"],
|
||||
useJavaScriptCoreRollback: false) == .javaScriptCore)
|
||||
#expect(ProviderPluginRuntime.resolveEngineKind(
|
||||
.automatic,
|
||||
environment: [:],
|
||||
useJavaScriptCoreRollback: true) == .javaScriptCore)
|
||||
#expect(ProviderPluginRuntime.resolveEngineKind(
|
||||
.automatic,
|
||||
environment: [ProviderPluginRuntime.engineEnvironmentKey: "quickjs"],
|
||||
useJavaScriptCoreRollback: true) == .quickJS)
|
||||
}
|
||||
#endif
|
||||
|
||||
@Test
|
||||
func `missing resource bundle throws a provider load error`() {
|
||||
#expect(throws: ProviderPluginError.load(CodexBarCoreResources.missingBundleMessage)) {
|
||||
@@ -489,6 +523,55 @@ struct ProviderPluginRuntimeTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `QuickJS engine supports bounded recursion on its dedicated stack`() async throws {
|
||||
let runtime = try ProviderPluginRuntime(
|
||||
source: Self.plugin(fetchBody: """
|
||||
function recurse(depth) {
|
||||
return depth <= 0 ? 0 : 1 + recurse(depth - 1);
|
||||
}
|
||||
return { primary: { usedPercent: recurse(100) } };
|
||||
"""),
|
||||
engine: .quickJS)
|
||||
|
||||
let snapshot = try await runtime.fetchUsage(secrets: ["TEST_KEY": "secret"])
|
||||
|
||||
#expect(snapshot.primary?.usedPercent == 100)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `QuickJS engine reports recursion beyond its JavaScript stack limit`() async throws {
|
||||
// Production geometry only: QuickJS's overflow guard is unreliable when the native
|
||||
// margin above the JS limit is thin (macOS crashes instead of throwing — see the
|
||||
// quickjs-ng/zipline reports), so a deliberately starved worker stack turns this
|
||||
// test into a layout-lottery process crash on CI runners. The default 4 MiB worker
|
||||
// with the 1 MiB JS limit leaves 3 MiB of margin for the guard and throw path while
|
||||
// still proving the semantics: over-limit recursion yields a clean script error.
|
||||
let engine = try Self.quickJSEngine(
|
||||
source: Self.plugin(fetchBody: """
|
||||
function recurse(depth) {
|
||||
return depth <= 0 ? 0 : 1 + recurse(depth - 1);
|
||||
}
|
||||
return { primary: { usedPercent: recurse(100000) } };
|
||||
"""),
|
||||
workerStackSizeBytes: QuickJSRuntimeLimits.nativeStackSizeBytes)
|
||||
|
||||
do {
|
||||
_ = try await Self.fetchUsage(engine: engine)
|
||||
Issue.record("Expected stack overflow")
|
||||
} catch let error as ProviderPluginError {
|
||||
guard case let .script(message) = error else {
|
||||
Issue.record("Unexpected plugin error: \(error)")
|
||||
return
|
||||
}
|
||||
let normalizedMessage = message.lowercased()
|
||||
#expect(normalizedMessage.contains("stack"))
|
||||
#expect(normalizedMessage.contains("overflow") || normalizedMessage.contains("exceeded"))
|
||||
} catch {
|
||||
Issue.record("Unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `hung script times out and next fetch uses a fresh context`() async throws {
|
||||
let runtime = try ProviderPluginRuntime(
|
||||
@@ -532,6 +615,41 @@ struct ProviderPluginRuntimeTests {
|
||||
"""
|
||||
}
|
||||
|
||||
private static func quickJSEngine(
|
||||
source: String,
|
||||
workerStackSizeBytes: Int) throws -> QuickJSProviderPluginEngine
|
||||
{
|
||||
let bundle = try #require(CodexBarCoreResources.bundle)
|
||||
let preludeURL = try #require(bundle.url(
|
||||
forResource: "provider-plugin-prelude",
|
||||
withExtension: "js"))
|
||||
let preludeSource = try String(contentsOf: preludeURL, encoding: .utf8)
|
||||
return try QuickJSProviderPluginEngine.make(
|
||||
source: source,
|
||||
preludeSource: preludeSource,
|
||||
transport: ProviderHTTPTransportHandler { _ in throw URLError(.unsupportedURL) },
|
||||
timeout: ProviderPluginRuntime.defaultTimeout,
|
||||
responseSizeLimit: ProviderPluginRuntime.maximumResponseBytes,
|
||||
rejectsNonSuccessResponses: false,
|
||||
allowsDynamicID: false,
|
||||
workerStackSizeBytes: workerStackSizeBytes)
|
||||
}
|
||||
|
||||
private static func fetchUsage(engine: QuickJSProviderPluginEngine) async throws -> UsageSnapshot {
|
||||
let result = await withCheckedContinuation { continuation in
|
||||
engine.fetch(
|
||||
settings: [:],
|
||||
secrets: ["TEST_KEY": "secret"],
|
||||
now: Date(),
|
||||
timeZone: .current,
|
||||
contextOptions: .production,
|
||||
cookieResolver: nil,
|
||||
instanceCookieResolver: nil)
|
||||
{ continuation.resume(returning: $0) }
|
||||
}
|
||||
return try result.get()
|
||||
}
|
||||
|
||||
private static func transport(
|
||||
recorder: RequestRecorder,
|
||||
body: String = #"{"ok":true}"#) -> ProviderHTTPTransportHandler
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
#if os(macOS)
|
||||
import Darwin
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct ProviderPluginEngineBenchmarkTests {
|
||||
private static let bundledPlugins = [
|
||||
"clawrouter", "crof", "deepgram", "manus", "openai", "openrouter", "perplexity", "poe", "qoder",
|
||||
"sub2api", "synthetic", "t3chat", "venice", "xai", "zai",
|
||||
]
|
||||
private static let creationSamples = 5
|
||||
private static let fetchIterations = 50
|
||||
private static let now = Date(timeIntervalSince1970: 1_785_816_000)
|
||||
|
||||
@Test
|
||||
func `compare provider plugin engines`() async throws {
|
||||
guard ProcessInfo.processInfo.environment["CODEXBAR_PLUGIN_BENCHMARK"] == "1" else { return }
|
||||
|
||||
let javaScriptCore = try await self.measure(engine: .javaScriptCore, label: "JavaScriptCore")
|
||||
let quickJS = try await self.measure(engine: .quickJS, label: "QuickJS")
|
||||
Self.printCreationTable([javaScriptCore, quickJS])
|
||||
Self.printFetchTable([javaScriptCore, quickJS])
|
||||
}
|
||||
|
||||
private func measure(engine: ProviderPluginEngineKind, label: String) async throws -> EngineResult {
|
||||
var creationMilliseconds: [String: Double] = [:]
|
||||
for plugin in Self.bundledPlugins {
|
||||
var samples: [Double] = []
|
||||
for _ in 0..<Self.creationSamples {
|
||||
let started = ContinuousClock.now
|
||||
let runtime = try Self.runtime(plugin: plugin, engine: engine, transport: Self.fixtureTransport())
|
||||
samples.append(Self.milliseconds(since: started))
|
||||
withExtendedLifetime(runtime) {}
|
||||
}
|
||||
creationMilliseconds[plugin] = samples.sorted()[samples.count / 2]
|
||||
}
|
||||
|
||||
let memoryBefore = Self.physicalFootprintBytes()
|
||||
var peakMemory = memoryBefore
|
||||
var retainedContexts: [ProviderPluginRuntime] = []
|
||||
for plugin in Self.bundledPlugins {
|
||||
try retainedContexts.append(Self.runtime(
|
||||
plugin: plugin,
|
||||
engine: engine,
|
||||
transport: Self.fixtureTransport()))
|
||||
peakMemory = max(peakMemory, Self.physicalFootprintBytes())
|
||||
}
|
||||
let memoryDeltaPerContext = Double(peakMemory - memoryBefore) / Double(retainedContexts.count)
|
||||
withExtendedLifetime(retainedContexts) {}
|
||||
|
||||
var fetchMilliseconds: [String: Double] = [:]
|
||||
for plugin in ["poe", "openrouter", "crof"] {
|
||||
let runtime = try Self.runtime(plugin: plugin, engine: engine, transport: Self.fixtureTransport())
|
||||
_ = try await Self.fetch(plugin: plugin, runtime: runtime)
|
||||
let started = ContinuousClock.now
|
||||
for _ in 0..<Self.fetchIterations {
|
||||
_ = try await Self.fetch(plugin: plugin, runtime: runtime)
|
||||
}
|
||||
fetchMilliseconds[plugin] = Self.milliseconds(since: started)
|
||||
}
|
||||
|
||||
return EngineResult(
|
||||
label: label,
|
||||
creationMilliseconds: creationMilliseconds,
|
||||
fetchMilliseconds: fetchMilliseconds,
|
||||
memoryDeltaPerContextBytes: memoryDeltaPerContext)
|
||||
}
|
||||
|
||||
private static func runtime(
|
||||
plugin: String,
|
||||
engine: ProviderPluginEngineKind,
|
||||
transport: any ProviderHTTPTransport) throws -> ProviderPluginRuntime
|
||||
{
|
||||
let bundle = try #require(CodexBarCoreResources.bundle)
|
||||
let sourceURL = try #require(bundle.url(forResource: plugin, withExtension: "js"))
|
||||
let source = try String(contentsOf: sourceURL, encoding: .utf8)
|
||||
try ProviderPluginSourceLint.validateBundled(source, name: plugin)
|
||||
return try ProviderPluginRuntime(source: source, transport: transport, engine: engine)
|
||||
}
|
||||
|
||||
private static func fetch(plugin: String, runtime: ProviderPluginRuntime) async throws -> UsageSnapshot {
|
||||
let secretKey = switch plugin {
|
||||
case "poe": "POE_API_KEY"
|
||||
case "openrouter": "OPENROUTER_API_KEY"
|
||||
case "crof": "CROF_API_KEY"
|
||||
default: preconditionFailure("missing benchmark secret for \(plugin)")
|
||||
}
|
||||
return try await runtime.fetchUsage(secrets: [secretKey: "fixture-key"], now: Self.now)
|
||||
}
|
||||
|
||||
private static func fixtureTransport() -> ProviderHTTPTransportHandler {
|
||||
ProviderHTTPTransportHandler { request in
|
||||
let body: String
|
||||
switch request.url?.path {
|
||||
case "/usage/current_balance":
|
||||
body = #"{"current_point_balance":2500}"#
|
||||
case "/usage/points_history":
|
||||
body = Self.poeHistoryPage(request.url)
|
||||
case "/api/v1/credits":
|
||||
body = #"{"data":{"total_credits":100,"total_usage":40}}"#
|
||||
case "/api/v1/key":
|
||||
body = #"{"data":{"limit":20,"limit_remaining":15,"limit_reset":"monthly","usage":5,"usage_daily":1,"usage_weekly":2,"usage_monthly":4,"rate_limit":{"requests":120,"interval":"10s"}}}"#
|
||||
case "/usage_api", "/usage_api/":
|
||||
body = #"{"credits":9.9999,"requests_plan":1000,"usable_requests":998}"#
|
||||
default:
|
||||
throw BenchmarkError.unexpectedURL(request.url)
|
||||
}
|
||||
let response = try #require(HTTPURLResponse(
|
||||
url: request.url!,
|
||||
statusCode: 200,
|
||||
httpVersion: nil,
|
||||
headerFields: ["Content-Type": "application/json"]))
|
||||
return (Data(body.utf8), response)
|
||||
}
|
||||
}
|
||||
|
||||
private static func poeHistoryPage(_ url: URL?) -> String {
|
||||
let components = url.flatMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) }
|
||||
let cursor = components?.queryItems?.first(where: { $0.name == "starting_after" })?.value
|
||||
let page = cursor.flatMap { Int($0.replacingOccurrences(of: "page-", with: "")) } ?? 0
|
||||
let entries = (0..<100).map { index in
|
||||
let queryID = "entry-\(page)-\(index)"
|
||||
let points = Double((page * 100) + index + 1) / 10
|
||||
return #"{"query_id":"\#(queryID)","creation_time":1785772800000000,"bot_name":"fixture-model","usage_type":"API","cost_points":\#(points),"cost_usd":0.01}"#
|
||||
}.joined(separator: ",")
|
||||
let nextCursor = page < 4 ? #""page-\#(page + 1)""# : "null"
|
||||
return #"{"data":[\#(entries)],"next_cursor":\#(nextCursor)}"#
|
||||
}
|
||||
|
||||
private static func physicalFootprintBytes() -> UInt64 {
|
||||
var info = task_vm_info_data_t()
|
||||
var count = mach_msg_type_number_t(
|
||||
MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<natural_t>.size)
|
||||
let result = withUnsafeMutablePointer(to: &info) { pointer in
|
||||
pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
|
||||
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count)
|
||||
}
|
||||
}
|
||||
return result == KERN_SUCCESS ? info.phys_footprint : 0
|
||||
}
|
||||
|
||||
private static func milliseconds(since started: ContinuousClock.Instant) -> Double {
|
||||
let duration = started.duration(to: .now)
|
||||
return Double(duration.components.seconds) * 1000
|
||||
+ Double(duration.components.attoseconds) / 1_000_000_000_000_000
|
||||
}
|
||||
|
||||
private static func printCreationTable(_ results: [EngineResult]) {
|
||||
print("\nProvider plugin creation + manifest load (median of \(self.creationSamples), milliseconds)")
|
||||
print("| Plugin | JavaScriptCore | QuickJS |")
|
||||
print("| --- | ---: | ---: |")
|
||||
for plugin in self.bundledPlugins {
|
||||
let values = results.map { $0.creationMilliseconds[plugin, default: 0] }
|
||||
print(String(format: "| %@ | %.3f | %.3f |", plugin, values[0], values[1]))
|
||||
}
|
||||
}
|
||||
|
||||
private static func printFetchTable(_ results: [EngineResult]) {
|
||||
print("\nProvider plugin fetch benchmark (\(self.fetchIterations) iterations, milliseconds)")
|
||||
print("| Engine | Poe | OpenRouter | Crof | Rough peak memory delta/context |")
|
||||
print("| --- | ---: | ---: | ---: | ---: |")
|
||||
for result in results {
|
||||
print(String(
|
||||
format: "| %@ | %.3f | %.3f | %.3f | %.1f KiB |",
|
||||
result.label,
|
||||
result.fetchMilliseconds["poe", default: 0],
|
||||
result.fetchMilliseconds["openrouter", default: 0],
|
||||
result.fetchMilliseconds["crof", default: 0],
|
||||
result.memoryDeltaPerContextBytes / 1024))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EngineResult {
|
||||
let label: String
|
||||
let creationMilliseconds: [String: Double]
|
||||
let fetchMilliseconds: [String: Double]
|
||||
let memoryDeltaPerContextBytes: Double
|
||||
}
|
||||
|
||||
private enum BenchmarkError: LocalizedError {
|
||||
case unexpectedURL(URL?)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .unexpectedURL(url): "unexpected benchmark URL: \(url?.absoluteString ?? "nil")"
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+58
-18
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "JavaScriptCore provider-plugin prototype API, safety boundary, enablement, and limitations."
|
||||
summary: "JavaScript provider-plugin prototype API, engine benchmark, safety boundary, enablement, and limitations."
|
||||
read_when:
|
||||
- Working on the JavaScript provider prototype
|
||||
- Converting a first-party provider to a bundled JavaScript resource
|
||||
@@ -17,9 +17,49 @@ system: IDs remain compile-time `UsageProvider` cases and scripts ship inside Co
|
||||
ClawRouter, Deepgram, sub2api, Synthetic, Poe, xAI, and z.ai use the same bundled script on Apple platforms and Linux;
|
||||
their native fetch twins have been removed.
|
||||
|
||||
The runtime selects JavaScriptCore by default on Apple platforms and QuickJS on Linux. Set
|
||||
`CODEXBAR_PLUGIN_ENGINE=quickjs` on macOS to exercise QuickJS locally. QuickJS uses a 20-second in-engine interrupt
|
||||
watchdog, a 64 MiB heap limit, and a 2 MiB JavaScript stack limit; JavaScriptCore retains the existing worker behavior.
|
||||
The runtime selects QuickJS on every platform. QuickJS uses a 20-second in-engine interrupt watchdog, a 64 MiB heap
|
||||
limit, and a 2 MiB JavaScript stack limit. On Apple platforms, set `CODEXBAR_PLUGIN_ENGINE=jsc` or enable the
|
||||
JavaScriptCore rollback in **Settings → Debug → Provider Plugins** and restart CodexBar. An explicit engine environment
|
||||
value overrides the persisted Debug setting. JavaScriptCore remains in-tree for rollback and A/B drift detection.
|
||||
|
||||
## Engine benchmark
|
||||
|
||||
The test-only `ProviderPluginEngineBenchmarkTests` instrumentation compares both engines without pass/fail thresholds.
|
||||
Run it on macOS with
|
||||
`CODEXBAR_PLUGIN_BENCHMARK=1 swift test --filter ProviderPluginEngineBenchmarkTests`. The August 8, 2026 baseline below
|
||||
was captured from a Swift debug build on an Apple M3 Ultra. Creation includes loading and linting each bundled source,
|
||||
creating its runtime, and reading its manifest; values are the median of five samples in milliseconds.
|
||||
|
||||
| Bundled plugin | JavaScriptCore | QuickJS |
|
||||
| --- | ---: | ---: |
|
||||
| clawrouter | 2.167 | 3.563 |
|
||||
| crof | 1.606 | 1.736 |
|
||||
| deepgram | 2.074 | 3.127 |
|
||||
| manus | 0.907 | 4.241 |
|
||||
| openai | 2.859 | 4.279 |
|
||||
| openrouter | 1.901 | 3.333 |
|
||||
| perplexity | 1.114 | 3.861 |
|
||||
| poe | 1.972 | 3.681 |
|
||||
| qoder | 0.863 | 2.362 |
|
||||
| sub2api | 2.848 | 4.018 |
|
||||
| synthetic | 2.617 | 5.985 |
|
||||
| t3chat | 1.521 | 1.734 |
|
||||
| venice | 1.483 | 1.881 |
|
||||
| xai | 1.359 | 2.470 |
|
||||
| zai | 2.862 | 8.986 |
|
||||
|
||||
Fetch timings reuse one context for 50 iterations with fixture transport. Poe exercises all five history pages with
|
||||
100 entries per page, OpenRouter performs its credits and key requests, and Crof performs its single usage request.
|
||||
Memory is a rough macOS task physical-footprint delta while retaining all 15 contexts, divided by context count.
|
||||
|
||||
| Engine | Poe (50) | OpenRouter (50) | Crof (50) | Rough peak delta/context |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| JavaScriptCore | 321.237 ms | 36.424 ms | 23.917 ms | 806.4 KiB |
|
||||
| QuickJS | 1000.066 ms | 90.610 ms | 38.119 ms | 117.3 KiB |
|
||||
|
||||
At this representative workload QuickJS trades roughly 1.6–3.1× fetch time for a much smaller measured context
|
||||
footprint. All operations stay well below the 20-second watchdog; these figures are instrumentation for future engine
|
||||
work, not a performance contract.
|
||||
|
||||
Plugin manifests and their projected snapshots now carry a validated `ProviderInstanceID`. The prototype still maps
|
||||
that instance ID to an existing first-party `UsageProvider` before using browser-cookie brokerage or other bespoke
|
||||
@@ -92,9 +132,9 @@ Cookie plugins omit `auth`, declare `capabilities: ["browser-cookies"]`, and lis
|
||||
|
||||
## `ctx` reference
|
||||
|
||||
`ctx` exists only as the argument to `fetchUsage`; it is not a global. JavaScriptCore supplies standard ECMAScript
|
||||
built-ins, but no browser or Node host environment. Tests assert that `fetch`, `XMLHttpRequest`, `setTimeout`, and
|
||||
`setInterval` are undefined.
|
||||
`ctx` exists only as the argument to `fetchUsage`; it is not a global. QuickJS and the JavaScriptCore rollback engine
|
||||
supply standard ECMAScript built-ins, but no browser or Node host environment. Tests assert that `fetch`,
|
||||
`XMLHttpRequest`, `setTimeout`, and `setInterval` are undefined.
|
||||
|
||||
- `await ctx.http.getJSON(url, opts?)` performs a GET and returns `{status, headers, json}`.
|
||||
- `await ctx.http.get(url, opts?)` performs a GET and returns `{status, headers, bodyText}`.
|
||||
@@ -173,18 +213,18 @@ bound violation fails the entire fetch with its property path.
|
||||
|
||||
## Concurrency and execution limit
|
||||
|
||||
Each runtime owns one `JSContext` confined to a dedicated serial dispatch queue; `JSContext` and every `JSValue` remain
|
||||
on that executor. Promise `then`/rejection callbacks converge on a lock-protected checked continuation gate, so network,
|
||||
timeout, and script completion can resume Swift exactly once. The exported `JSContextGroupSetExecutionTimeLimit` symbol
|
||||
has no declaration in the public macOS JavaScriptCore headers, so the prototype does not bind that private SPI.
|
||||
Each runtime owns one engine context confined to a dedicated serial worker. QuickJS uses a 4 MiB native-stack thread,
|
||||
leaving guard-page margin beyond its 2 MiB JavaScript stack limit. Promise `then`/rejection callbacks converge on a
|
||||
lock-protected checked continuation gate, so network, timeout, and script completion can resume Swift exactly once.
|
||||
QuickJS's `JS_SetInterruptHandler` stops evaluation on that worker when the 20-second watchdog fires;
|
||||
the poisoned context is discarded and the next refresh creates a fresh one, which the hung-script recovery test proves.
|
||||
|
||||
Instead, a 20-second wall-clock watchdog fails the refresh and discards the poisoned worker; the next refresh creates a
|
||||
new context on a fresh executor, which the hung-script recovery test proves. This keeps refresh callers responsive but
|
||||
cannot interrupt the abandoned JavaScriptCore thread, which may remain alive until process exit. A production plugin
|
||||
runtime needs a public interrupt API or a killable helper-process boundary before accepting untrusted scripts.
|
||||
|
||||
The same watchdog is production-default for first-party cut-over providers. It is part of the shared runtime, not the
|
||||
prototype flag, so cut-over providers retain timeout and fresh-context recovery without `CODEXBAR_JS_PROVIDERS`.
|
||||
The same hard-interrupt watchdog is production-default for first-party cut-over providers. It is part of the shared
|
||||
runtime, not the prototype flag, so cut-over providers retain timeout and fresh-context recovery without
|
||||
`CODEXBAR_JS_PROVIDERS`. The Apple-only JavaScriptCore rollback still uses a `JSContext` and `JSValue` objects confined
|
||||
to its executor. The exported `JSContextGroupSetExecutionTimeLimit` symbol has no declaration in public macOS headers,
|
||||
so the rollback path does not bind that private SPI: its watchdog returns to the caller and discards the poisoned
|
||||
context, but it cannot interrupt the abandoned JavaScriptCore thread, which may remain alive until process exit.
|
||||
|
||||
## Current limitations
|
||||
|
||||
|
||||
+11
-6
@@ -81,8 +81,9 @@ example, `acme-usage` and `API_KEY` use `CODEXBAR_PLUGIN_ACME_USAGE_API_KEY`.
|
||||
|
||||
## `ctx` API
|
||||
|
||||
`ctx` exists only during `fetchUsage`. CodexBar uses JavaScriptCore on Apple platforms and QuickJS on Linux; both
|
||||
provide ECMAScript built-ins but no browser or Node environment. `Intl` is engine-dependent and unavailable in QuickJS,
|
||||
`ctx` exists only during `fetchUsage`. CodexBar uses QuickJS on every platform; both QuickJS and the Apple-only
|
||||
JavaScriptCore rollback engine provide ECMAScript built-ins but no browser or Node environment. `Intl` is
|
||||
engine-dependent and unavailable in QuickJS,
|
||||
so portable third-party plugins must use the host helpers below instead of ECMA-402. `fetch`, `XMLHttpRequest`, timers,
|
||||
`require`, `process`, and filesystem APIs are unavailable.
|
||||
|
||||
@@ -120,9 +121,12 @@ response bytes are capped at 1 MiB. Request URLs must match a declared, approved
|
||||
Bundled first-party providers that have cut over to JavaScript use the shared runtime's 20-second hung-script watchdog.
|
||||
A timeout fails that refresh and discards the poisoned worker so the next refresh starts with a fresh context; this is
|
||||
production-default and does not depend on `CODEXBAR_JS_PROVIDERS`.
|
||||
On Linux, QuickJS enforces the watchdog in-engine with `JS_SetInterruptHandler`, caps the runtime heap at 64 MiB, and
|
||||
caps the JavaScript stack at 2 MiB. The interrupt terminates evaluation on its confined thread; timed-out scripts do not
|
||||
leave an abandoned evaluation thread behind.
|
||||
QuickJS enforces the watchdog in-engine with `JS_SetInterruptHandler`, caps the runtime heap at 64 MiB, and caps the
|
||||
JavaScript stack at 2 MiB. The interrupt terminates evaluation on its confined thread; timed-out scripts do not leave an
|
||||
abandoned evaluation thread behind. On Apple platforms, `CODEXBAR_PLUGIN_ENGINE=jsc` selects the JavaScriptCore rollback
|
||||
engine; the same rollback is available in **Settings → Debug → Provider Plugins** and takes effect after restarting
|
||||
CodexBar. JavaScriptCore has no public interrupt API, so a timed-out rollback-engine context is discarded but its
|
||||
abandoned evaluation thread can remain alive until process exit.
|
||||
|
||||
## Snapshot result
|
||||
|
||||
@@ -158,7 +162,8 @@ and 120 characters per detail string. Wrong types and limit violations fail the
|
||||
|
||||
## TypeScript
|
||||
|
||||
TypeScript files are transpiled with the bundled Sucrase 3.35.1 build using its `typescript` transform. Use ordinary
|
||||
TypeScript files are transpiled by the selected plugin engine with the bundled Sucrase 3.35.1 build using its
|
||||
`typescript` transform. Use ordinary
|
||||
type syntax but no module imports, JSX, decorators, or runtime TypeScript features that require module resolution.
|
||||
Transpiled output is cached in `~/Library/Caches/CodexBar/plugins/` under a filename containing the SHA-256 of the source
|
||||
and the Sucrase version. An unchanged file is a cache hit; any source or compiler-version change produces a new key.
|
||||
|
||||
Reference in New Issue
Block a user