From dfd0a0d828315f8a63709aa45fc2379e096f2ce0 Mon Sep 17 00:00:00 2001 From: Matthieu Veinhard Date: Sun, 12 Jul 2026 17:04:59 +0200 Subject: [PATCH 1/5] perf: cache SignatureSolver per player-JS + preprocessed-player fast path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applySignature built a fresh SignatureSolver per video: JSContext setup, meriyah/astring bundle eval, and a full parse of the ~2MB player JS every time — devastating on JIT-less JavaScriptCore (tvOS/iOS), where it costs 15s+ per video. - SignatureSolver.shared(forJS:) caches one solver per player-JS version - First batchSolve requests output_preprocessed and stores the preprocessed player; subsequent solves send it back and skip the parse - Solves serialized with a lock (JSContext is not thread-safe) - Task.checkCancellation before solver work so cancelled extractions don't burn CPU - Timing os_log (.default) around init and solve --- Sources/YouTubeKit/Extraction.swift | 3 +- Sources/YouTubeKit/SignatureSolver.swift | 64 +++++++++++++++++++++--- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/Sources/YouTubeKit/Extraction.swift b/Sources/YouTubeKit/Extraction.swift index 0da85e8..4e94143 100644 --- a/Sources/YouTubeKit/Extraction.swift +++ b/Sources/YouTubeKit/Extraction.swift @@ -394,7 +394,8 @@ class Extraction { #if canImport(JavaScriptCore) /// apply the decrypted signature to the stream manifest class func applySignature(streamManifest: inout [InnerTube.StreamingData.Format], videoInfo: InnerTube.VideoInfo, js: String) throws { - let solver = try SignatureSolver(js: js) + try Task.checkCancellation() + let solver = try SignatureSolver.shared(forJS: js) var sigInputs: [String] = [] var nInputs: [String] = [] diff --git a/Sources/YouTubeKit/SignatureSolver.swift b/Sources/YouTubeKit/SignatureSolver.swift index ad90a2b..447ab41 100644 --- a/Sources/YouTubeKit/SignatureSolver.swift +++ b/Sources/YouTubeKit/SignatureSolver.swift @@ -14,6 +14,33 @@ class SignatureSolver { private static let log = OSLog(SignatureSolver.self) + // MARK: Shared instance cache + // Solver construction (JSContext + meriyah/astring bundle eval) and the first + // solve (full player parse) are very expensive on JIT-less platforms (tvOS/iOS + // JavaScriptCore runs interpreted). Cache one solver per player-JS version so + // subsequent videos reuse the context and the preprocessed player. + private static let sharedLock = NSLock() + nonisolated(unsafe) private static var sharedSolver: (jsHash: Int, solver: SignatureSolver)? + + static func shared(forJS js: String) throws -> SignatureSolver { + let hash = js.hashValue + sharedLock.lock() + defer { sharedLock.unlock() } + if let cached = sharedSolver, cached.jsHash == hash { + return cached.solver + } + let start = Date() + let solver = try SignatureSolver(js: js) + os_log("solver init took %.2fs", log: log, type: .default, Date().timeIntervalSince(start)) + sharedSolver = (hash, solver) + return solver + } + + /// Player preprocessed by the first solve; skips the full player parse afterwards + private var preprocessedPlayer: String? + /// JSContext isn't thread-safe; the shared instance serializes solves + private let solveLock = NSLock() + private let vm = JSVirtualMachine() private let ctx: JSContext @@ -148,21 +175,44 @@ class SignatureSolver { } func batchSolve(request: SolveRequest) throws -> SolveResponse { + solveLock.lock() + defer { solveLock.unlock() } + + if #available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) { + try Task.checkCancellation() + } let requests = [ Request(type: .n, challenges: request.nInputs), Request(type: .sig, challenges: request.sigInputs) ] - let input = Input( - type: .player, - player: self.playerJS, - preprocessed_player: nil, - requests: requests, - output_preprocessed: false - ) + let input: Input + if let preprocessedPlayer { + input = Input( + type: .preprocessedPlayer, + player: nil, + preprocessed_player: preprocessedPlayer, + requests: requests, + output_preprocessed: false + ) + } else { + input = Input( + type: .player, + player: self.playerJS, + preprocessed_player: nil, + requests: requests, + output_preprocessed: true + ) + } + let solveStart = Date() let response = try solve(with: input) + os_log("batch solve took %.2fs (preprocessed: %{public}@)", log: Self.log, type: .default, Date().timeIntervalSince(solveStart), preprocessedPlayer != nil ? "yes" : "no") + + if preprocessedPlayer == nil { + preprocessedPlayer = response.preprocessed_player + } var nMap: [String: String] = [:] var sigMap: [String: String] = [:] From 119962b2ea76acc9602eed3d2a1d7ea7c241876b Mon Sep 17 00:00:00 2001 From: Matthieu Veinhard Date: Tue, 14 Jul 2026 11:27:30 +0200 Subject: [PATCH 2/5] Cache solver by direct playerJS comparison; check cancellation before init Avoids the O(N) hashValue computation and its (rare) collision risk that could return a wrong solver; checks task cancellation inside the lock before the expensive SignatureSolver init. --- Sources/YouTubeKit/SignatureSolver.swift | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/Sources/YouTubeKit/SignatureSolver.swift b/Sources/YouTubeKit/SignatureSolver.swift index 447ab41..38977ac 100644 --- a/Sources/YouTubeKit/SignatureSolver.swift +++ b/Sources/YouTubeKit/SignatureSolver.swift @@ -20,19 +20,26 @@ class SignatureSolver { // JavaScriptCore runs interpreted). Cache one solver per player-JS version so // subsequent videos reuse the context and the preprocessed player. private static let sharedLock = NSLock() - nonisolated(unsafe) private static var sharedSolver: (jsHash: Int, solver: SignatureSolver)? + nonisolated(unsafe) private static var sharedSolver: SignatureSolver? static func shared(forJS js: String) throws -> SignatureSolver { - let hash = js.hashValue sharedLock.lock() defer { sharedLock.unlock() } - if let cached = sharedSolver, cached.jsHash == hash { - return cached.solver + // Compare the retained player JS directly — Swift string equality is + // pointer-identity-fast in the common case and avoids the hash-collision + // risk of caching by hashValue (a collision would return a wrong solver). + if let cached = sharedSolver, cached.playerJS == js { + return cached + } + // A task can be cancelled while blocked on the lock; bail before the + // expensive init rather than tying up the thread pool. + if #available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) { + try Task.checkCancellation() } let start = Date() let solver = try SignatureSolver(js: js) os_log("solver init took %.2fs", log: log, type: .default, Date().timeIntervalSince(start)) - sharedSolver = (hash, solver) + sharedSolver = solver return solver } From ee24cda373a9cbbe6a9ce61bb4515f98d070c1c6 Mon Sep 17 00:00:00 2001 From: Matthieu Veinhard Date: Tue, 14 Jul 2026 11:33:03 +0200 Subject: [PATCH 3/5] Don't route CancellationError through the stale-JS retry path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The solver's checkCancellation throws CancellationError out of applySignature, which the retry catch treated as a stale-player-JS failure — clearing the shared JS cache and retrying. Propagate cancellation immediately instead, so a cancelled extraction stops cleanly without churning the cache for concurrent extractions. --- Sources/YouTubeKit/YouTube.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/YouTubeKit/YouTube.swift b/Sources/YouTubeKit/YouTube.swift index c3935b6..29ea08c 100644 --- a/Sources/YouTubeKit/YouTube.swift +++ b/Sources/YouTubeKit/YouTube.swift @@ -250,6 +250,10 @@ public class YouTube { do { try await Extraction.applySignature(streamManifest: &streamManifest, videoInfo: videoInfo, js: js) + } catch is CancellationError { + // Cancellation is not a stale-JS failure — propagate it + // immediately instead of clearing the cache and retrying. + throw CancellationError() } catch { // to force an update to the js file, we clear the cache and retry _js = nil From d5f94a075240e2e09a8ffe3d3f6eba1e37da23b8 Mon Sep 17 00:00:00 2001 From: Matthieu Veinhard Date: Tue, 14 Jul 2026 11:46:21 +0200 Subject: [PATCH 4/5] Cache multiple solvers (bounded MRU) instead of a single slot A single-entry cache means alternating between two player-JS variants in one session (e.g. web vs TV/embed) evicts and rebuilds each time. Keep a small MRU list keyed by player JS so each variant's prepared solver is reused, bounded to cap JSContext/player memory. --- Sources/YouTubeKit/SignatureSolver.swift | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/Sources/YouTubeKit/SignatureSolver.swift b/Sources/YouTubeKit/SignatureSolver.swift index 38977ac..0caeccc 100644 --- a/Sources/YouTubeKit/SignatureSolver.swift +++ b/Sources/YouTubeKit/SignatureSolver.swift @@ -20,16 +20,23 @@ class SignatureSolver { // JavaScriptCore runs interpreted). Cache one solver per player-JS version so // subsequent videos reuse the context and the preprocessed player. private static let sharedLock = NSLock() - nonisolated(unsafe) private static var sharedSolver: SignatureSolver? + /// Small most-recently-used cache of solvers keyed by their player JS. + /// Bounded because each solver retains a JSContext and the ~2 MB player; + /// a session rarely uses more than a couple of player variants (e.g. web + /// vs TV/embed), so alternating between them still reuses each solver. + nonisolated(unsafe) private static var sharedSolvers: [SignatureSolver] = [] + private static let maxCachedSolvers = 4 static func shared(forJS js: String) throws -> SignatureSolver { sharedLock.lock() defer { sharedLock.unlock() } - // Compare the retained player JS directly — Swift string equality is + // Match on the retained player JS directly — Swift string equality is // pointer-identity-fast in the common case and avoids the hash-collision // risk of caching by hashValue (a collision would return a wrong solver). - if let cached = sharedSolver, cached.playerJS == js { - return cached + if let idx = sharedSolvers.firstIndex(where: { $0.playerJS == js }) { + let solver = sharedSolvers.remove(at: idx) + sharedSolvers.insert(solver, at: 0) // promote to most-recently-used + return solver } // A task can be cancelled while blocked on the lock; bail before the // expensive init rather than tying up the thread pool. @@ -39,7 +46,10 @@ class SignatureSolver { let start = Date() let solver = try SignatureSolver(js: js) os_log("solver init took %.2fs", log: log, type: .default, Date().timeIntervalSince(start)) - sharedSolver = solver + sharedSolvers.insert(solver, at: 0) + if sharedSolvers.count > maxCachedSolvers { + sharedSolvers.removeLast() + } return solver } From 370f93529de2c230ce6673d7c687882130421daf Mon Sep 17 00:00:00 2001 From: Matthieu Veinhard Date: Tue, 14 Jul 2026 11:57:07 +0200 Subject: [PATCH 5/5] Guard nonisolated(unsafe) behind #if swift(>=5.10) Matches the existing pattern for the __js caches; the package supports swift-tools 5.8, where the unconditional attribute fails to parse. --- Sources/YouTubeKit/SignatureSolver.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/YouTubeKit/SignatureSolver.swift b/Sources/YouTubeKit/SignatureSolver.swift index 0caeccc..58fc892 100644 --- a/Sources/YouTubeKit/SignatureSolver.swift +++ b/Sources/YouTubeKit/SignatureSolver.swift @@ -24,7 +24,11 @@ class SignatureSolver { /// Bounded because each solver retains a JSContext and the ~2 MB player; /// a session rarely uses more than a couple of player variants (e.g. web /// vs TV/embed), so alternating between them still reuses each solver. +#if swift(>=5.10) nonisolated(unsafe) private static var sharedSolvers: [SignatureSolver] = [] +#else + private static var sharedSolvers: [SignatureSolver] = [] +#endif private static let maxCachedSolvers = 4 static func shared(forJS js: String) throws -> SignatureSolver {