Skip to content

feat: recover FairPlay context after lease end - #4

Merged
LYJW131 merged 2 commits into
mainfrom
agent/lease-recovery
Aug 17, 2026
Merged

feat: recover FairPlay context after lease end#4
LYJW131 merged 2 commits into
mainfrom
agent/lease-recovery

Conversation

@LYJW131

@LYJW131 LYJW131 commented Aug 17, 2026

Copy link
Copy Markdown
Member

What changed

Lease-end and playback-error callbacks no longer exit(1) and no longer ignore the event. They enqueue an async recovery; a dedicated worker coalesces bursts, re-requests the playback lease, resets FairPlay contexts, and rebuilds preshareCtx under the existing kd_context_mutex.

Incoming decrypt/m3u8 work is refused only while that reset is in progress (Refreshing). Failed refreshes retry with bounded exponential backoff (1s → 2s → 5s → 10s → 30s). HTTP servers stay up.

This is an adapted port of the idea in WorldObservationLog/wrapper#62, rewritten for this fork's concurrent decrypt workers and exception-safe mutex.

Why

44cbb9b stopped the process from dying on lease callbacks, but refresh_decrypt_ctx() was never called. After Apple ends the lease (commonly code 3084), the wrapper stays listening with a stale FairPlay context: it looks healthy and decrypt/m3u8 keep failing.

The worker exception path only called requestLease and did not reset or rebuild contexts, so it could not recover either.

How it works

  • endLeaseCb / pbErrCb only enqueue and return. No library calls on the lease-manager thread.
  • Recovery worker drains the queue (one refresh per burst), then calls refresh_decrypt_ctx() from C++ with try/catch.
  • refresh_decrypt_ctx() runs via run_with_mutex so requestLease / resetAllContexts / key setup cannot leak kd_context_mutex if they throw.
  • Rebuild uses getKdContextLocked directly to avoid re-entering the mutex.
  • handle() / handle_m3u8() fail fast only during Refreshing. Scheduled/Failed still accept requests.
  • A decrypt-thread exception schedules recovery only if the daemon is currently Running, so an in-flight failure during reset does not start a second cycle.

Intentionally not taken from upstream WorldObservationLog#62

  • drm-state file (no consumer in this repo)
  • DNS resolver change
  • wrapper-rootless.c namespace rewrite
  • Dockerfile.build / drift-check CI
  • their unlocked getKdContext (would regress Serialize FairPlay context creation #1 / 6237205)

Impact

  • lease expiry is recovered in-process instead of leaving a zombie daemon
  • existing FairPlay serialization and exception-safe lock release are unchanged
  • no wire-protocol or CLI changes

Validation

  • clang++ -std=c++11 -Wall -Werror -fsyntax-only main.cpp on the authoring host
  • not cross-compiled here (no Android NDK on this machine); CI build job is the compile check

Stop treating endLeaseCb/pbErrCb as either fatal or ignorable. Enqueue
async recovery, refresh the playback lease and FairPlay contexts under
the existing kd_context_mutex, and refuse new decrypt/m3u8 work only
while that reset is in flight.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a7902f1535

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread main.c Outdated
Comment on lines +599 to +602
if (is_refreshing()) {
fprintf(stderr, "[.] decrypt sample aborted: lease recovery in progress\n");
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep contexts alive until in-flight decrypts finish

When a client passes this check and then blocks in readfull waiting for the sample body, the recovery worker can switch to Refreshing and call resetAllContexts before execution reaches NfcRKVnxuKZy04KWbdFu71Ou. The decrypt then uses the previously obtained kdContext after its backing FairPlay context has been reset, which can fail or access invalid state during ordinary lease recovery with active streams. An atomic state check cannot close this race; the reset must wait for in-flight decrypt operations (or otherwise retain their contexts) before invalidating them.

Useful? React with 👍 / 👎.

@LYJW131 LYJW131 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

方向是对的:44cbb9b 只是把「进程死掉」换成了「僵尸态活着」,这个 PR 补上了真正缺的那一半。把 FairPlay 调用挪出 lease-manager 回调线程、refresh_decrypt_ctx()run_with_mutex 而不是自己 lock/unlock、rebuild 时直接调 getKdContextLocked 避免重入——这几个决定都和 6237205 立下的规矩一致,没有走回头路。不从上游 WorldObservationLog#62 抄 unlocked getKdContext 也是对的。

不过有一个我认为需要在合并前解决的问题,另外三个是次要的。

1. resetAllContexts() 会和在途解密竞争 → use-after-free(阻塞项)

kd_context_mutex 按设计只保护 context 构造,worker 拿到 kdContext 后整个 sample 循环是无锁跑的(main.c:441-443 的注释就是这么写的)。恢复线程持 kd_context_mutexresetAllContexts() 时,解密线程并不持有这把锁,所以互斥不成立。sample 循环里那个 is_refreshing() 是 check-then-act——中间隔着一次可能阻塞很久的 readfull(connfd, sample, size),原子变量只给可见性、不给互斥。

这是本 PR 新引入的:mainrefresh_decrypt_ctx() 零调用者,resetAllContexts() 在运行时从未执行过。这个 PR 第一次让它跑起来,且正好跑在「有活跃解密流时租约到期」这个最容易撞上的场景。为了「不死」写的恢复路径反而变成新的崩溃源。

细节和三种修法写在 main.c:599 的行内评论里,我倾向 in-flight 计数 + 条件变量那种,最贴近现有结构。

2. refresh 期间到达的事件会立刻触发无退避的第二轮

endLeaseCb / pbErrCb 无条件入队(不像 request_lease_recovery() 会看 state)。成功路径把 consec_fails 清零,于是紧接着的那一轮完全跳过 sleeprequestLease 若自己会同步触发 endLeaseCb,就是零间隔自激循环。详见 main.cpp:160 行内。

3. m3u8 拒绝路径写 1 字节,既有失败路径写 2 字节

sizeof("\n") 是 2(含 \0)。同一函数里同一语义、两种线格式。main.c:802

4. decrypt 拒绝是裸 EOF;Failed 期间照收请求

对称性和取舍问题,main.c:555 行内。

其它核对过、没问题的

  • refresh_decrypt_ctx 签名从 void () 改成 int (void),全树只有 main.cpp:13 一处声明,一致;没有遗留旧调用点。
  • refresh_decrypt_ctx_lockedpreshareCtx = NULLresetAllContexts() 之后、getKdContextLocked 之前,所以 preshare 早退分支不会命中陈旧值;ready 判据取 preshareCtx != NULL 也确实只在 key setup 真成功时为真。
  • 异常穿过 refresh_decrypt_ctx 这个 C 帧的行为和现有 handle()handle_cpp 那条路一致,锁由 run_with_mutex 释放。
  • main.cpp 的 include 齐全(<unistd.h> 对应 sleep<vector>/<queue>/<atomic> 都有用到),enum class / %zu 在 C++11 下没问题;CI 的 NDK r23b 交叉编译带 -Wall -Werror 是绿的。
  • start_recovery_thread()leaseMgr / FHinstance 就绪之后调用;在那之前触发的回调只是入队,worker 起来后照常消费。
  • README 的描述和代码行为对得上(「refused only while that reset is in progress」确实只对应 Refreshing)。

第 1 条修掉我就没有别的意见了。要我直接在 agent/lease-recovery 上把这几处改了也可以,说一声。


Generated by Claude Code

Comment thread main.c Outdated
Comment on lines +599 to +602
if (is_refreshing()) {
fprintf(stderr, "[.] decrypt sample aborted: lease recovery in progress\n");
return;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

阻塞项:这个检查挡不住 resetAllContexts() 和在途解密的竞争,会 use-after-free。

kd_context_mutex 按设计只保护 context 的构造——main.c:441-443 的注释写得很明确:「Bound only context construction; workers still decrypt samples concurrently after acquiring their contexts.」

所以 worker 拿到 kdContext 之后就把锁放掉了,下面这段全程无锁:

if (is_refreshing()) return;            // ← check
void *sample = malloc(size);
if (!readfull(connfd, sample, size))    // ← 阻塞在网络上,可以很久
    ...
NfcRKVnxuKZy04KWbdFu71Ou(*kdContext, 5, sample, sample, size);   // ← use

恢复线程那边:

g_recovery_state.store(RecoveryState::Refreshing);
refresh_decrypt_ctx();
  → run_with_mutex(&kd_context_mutex, refresh_decrypt_ctx_locked, ...)
      → resetAllContextsEv(FHinstance);   // 销毁所有 context,包括 worker 手里那个

run_with_mutex 拿的是 kd_context_mutex,而解密线程此刻没有持有它,所以这把锁在这里提供不了任何互斥。is_refreshing() 是典型的 check-then-act:中间隔着一次可能阻塞几百毫秒的 readfull,原子变量只保证可见性、不保证互斥。窗口一命中,*kdContext 就是对已释放内存的解引用——注意这里还是二次解引用,连 load 本身都可能直接 fault。

这个风险是本 PR 新引入的:mainrefresh_decrypt_ctx() 零调用者(git grep 只有定义那一处),resetAllContexts() 在运行时从来没被执行过。这个 PR 第一次让它跑起来,而且恰好跑在「有活跃解密流时租约到期」这个场景下——也就是最容易撞上的时刻。结果是本来为了「不死」写的恢复路径,反而成了新的崩溃源。

建议的修法(要能真正排他,不能只靠标志位):

  • 加一个 pthread_rwlock_t fp_ctx_lock:worker 在整个 sample 循环持 rdlock,恢复线程持 wrlock 包住 refresh_decrypt_ctx_locked。wrlock 同样要走 run_with_mutex 那套(换成 rwlock 版本的 guard),否则抛异常时照样泄锁。
  • 光加 rwlock 还不够——worker 可能在 reset 之前就拿到了 kdContext,等 reset 做完再去拿 rdlock,用的还是野指针。所以要配一个 generation 计数:取 context 时记下 generation,每轮解密在 rdlock 内比对一次,变了就直接 return 让客户端重连。
  • 更轻的做法是 in-flight 解密计数 + 条件变量:恢复线程在调 resetAllContexts() 前先等计数归零(带超时),期间靠 Refreshing 挡住新请求。

第三种最贴近现有结构,改动也最小。


Generated by Claude Code

Comment thread main.cpp
Comment on lines +160 to +170
if (ready) {
consec_fails = 0;
g_recovery_state.store(RecoveryState::Running);
fprintf(stderr, "[recovery] refresh ok, resuming\n");
} else {
consec_fails++;
g_recovery_state.store(RecoveryState::Failed);
fprintf(stderr, "[recovery] refresh failed (consecutive=%d)\n",
consec_fails);
schedule_recovery(kRetryInternal);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refresh 期间到达的事件会立刻触发第二轮 refresh,而且不走退避。

endLeaseCb / pbErrCb 是无条件入队的(不像 request_lease_recovery() 会先看 state 是不是 Running)。所以在 Refreshing 那段窗口里 Apple 再吐一批 lease-end,全都进队列。

等这一轮成功返回:

if (ready) {
    consec_fails = 0;                                  // ← 退避计数清零
    g_recovery_state.store(RecoveryState::Running);

循环回到顶部,wait 因为队列非空立即返回,consec_fails == 0 于是 if (consec_fails > 0) 不成立、完全跳过 sleep,直接又是一轮 requestLease + resetAllContexts + key setup。

PR 描述里说的「coalesce bursts」只在单次 drain 窗口内成立,跨不过 refresh 本身那段时间。更糟的情况是 requestLease 自己会同步触发 endLeaseCb(账号真被吊销、反复 3084 时很可能)——那就是一个零间隔的自激循环:每轮都打一次 Apple、每轮都 resetAllContexts() 把 context 全掀掉、Refreshing 反复翻转导致 decrypt/m3u8 断续被拒。而且因为 FPS key 端点未必跟着挂,ready 完全可能一直是 1,consec_fails 永远清零,退避永远不生效。

两个方向,建议都做:

  1. refresh 成功后把队列里在本轮开始之前之后积压的事件丢掉——这一轮已经处理过了。用一个单调递增的 refresh generation:入队时带上当时的 generation,drain 时只认 generation ≥ 本轮起始值的事件。
  2. 不管成功失败都设一个最小 refresh 间隔(比如 1s)。现在的退避只在失败路径上,成功路径完全没有节流。

Generated by Claude Code

Comment thread main.c Outdated

if (is_refreshing()) {
fprintf(stderr, "[.] m3u8 request refused: lease recovery in progress\n");
writefull(connfd, "\n", 1);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

同一个函数里表达「这次没有 m3u8」的两条路径,写到线上的字节数不一样:

  • 这里(拒绝):writefull(connfd, "\n", 1) → 1 字节
  • 下面 8 行(取失败):writefull(connfd, "\n", sizeof("\n"))2 字节sizeof("\n") 把结尾的 \0 也算进去了

客户端如果是按行读,两者等价;但只要有任何一处依赖已有的 2 字节 framing,新路径就和既有失败路径对不上。既然本仓库现有的「失败」线格式就是 2 字节,这里跟着写 sizeof("\n") 最省事。

(1 字节其实才是更干净的那个,但那是另一件事——真要统一成 1 字节,得连下面那处一起改,并且按 AGENTS.md「不留兼容路径」的规矩把上报侧一起对齐,不要两条路各留一份。)


Generated by Claude Code

Comment thread main.c
Comment on lines +555 to +558
if (is_refreshing()) {
fprintf(stderr, "[.] decrypt request refused: lease recovery in progress\n");
return;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

小问题,两条拒绝路径的语义不对称:m3u8 那边至少回一个 \n,decrypt 这边直接 returndecrypt_worker_thread 随后 close(connfd),客户端只看到协议中途一个裸 EOF。

站点侧因此没法区分「wrapper 正在恢复、等会儿重试」和「wrapper 挂了 / 解密失败」——两种情况在 socket 上长得一模一样。既然这个 PR 的卖点就是「HTTP servers stay up」,让调用方能看出来是暂时性拒绝才对得上。考虑回一个单字节的拒绝标记再关连接。

另外确认一下 Failed 状态的取舍:is_refreshing() 只在 Refreshing 为真,所以退避那 30 秒里请求照收,而此时 context 是已知失效的——客户端拿到的是静默失败,且 request_lease_recovery() 因为 state != Running 直接 no-op,连补一次恢复都不会。PR 描述里写了这是有意为之,那就算设计选择;只是长时间故障时,对外表现会是「一直在服务但一直解密不出来」,和 44cbb9b 之前那个僵尸态挺像的。要不要在 Failed 期间也拒收,值得再想一下。


Generated by Claude Code

resetAllContexts() frees every context the instance handed out. Decrypt
workers deliberately run unlocked -- kd_context_mutex bounds only context
construction, as the comment above it says -- so holding that mutex during
the reset excludes nothing: the workers are not holding it while they
decrypt. The is_refreshing() test in the sample loop did not close the gap
either. It is a check-then-act with a blocking readfull() of the sample
sitting inside the window, and an atomic gives visibility, not exclusion.
The result is a use-after-free on *kdContext, a double dereference, so even
the load can fault.

Nothing hit this before because refresh_decrypt_ctx() had no callers;
resetAllContexts() never ran. Wiring up recovery made it live, and made it
live precisely when streams are active and a lease expires -- the recovery
path added to stop the daemon dying became a way for it to crash.

Exclusion now has two halves, because the reset has to be safe against both
workers that are mid-decrypt and workers that were mid-decrypt before it
started:

  - A barrier. begin_refresh_barrier() publishes Refreshing and waits for
    g_active_decrypts to reach zero, both under g_ctx_mtx, so testing the
    state and taking a slot cannot race. A slot brackets one decrypt call
    with no socket I/O inside it, so the drain is quick; if it times out the
    refresh is deferred and retried rather than run under a wedged call.
  - A generation counter, bumped under kd_context_mutex next to the reset.
    A worker captures it with its context under that same mutex, so the pair
    cannot straddle a reset, and rechecks it per sample. That catches the
    worker that acquired its context before the reset and resumes after it,
    which the barrier alone cannot see.

Slot release lives in ~DecryptSlot for the same reason run_with_mutex
exists: the decrypt call can throw, main.c has no landing pads, and a
decrement written there would be skipped on the throw path, leaving the
recovery worker to wait out its full timeout on a slot never coming back.

Two smaller fixes:

Events queued during a refresh no longer trigger an immediate second one.
endLeaseCb/pbErrCb enqueue unconditionally, and success clears consec_fails,
so the backoff did not engage: a lease-end burst -- or a requestLease that
synchronously fires the callback -- spun refresh at full speed, tearing down
the context just rebuilt on every pass. A successful refresh now drops the
queue, since requestLease, resetAllContexts and a fresh preshare context all
happened after those events fired. A one-second floor between refreshes
backs that up independently of the failure backoff.

The m3u8 refusal path wrote one byte where the get-failure path eight lines
below writes sizeof("\n"), which is two. Both mean "no m3u8 this time" and
must not differ on the wire.

No wire-protocol or CLI changes. The refusal semantics of handle() and
handle_m3u8() are unchanged; a rejected decrypt still ends the connection,
which is how every other failure in handle() already signals the client.

LYJW131 commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

04ebae6 处理了 review 里的三条实际缺陷。CI build(NDK r23b 交叉编译,-Wall -Werror)绿。

1. UAF(阻塞项)

排他现在分两半做,因为 reset 要同时对付「正在解密的 worker」和「reset 开始前就已在解密的 worker」:

  • barrier —— begin_refresh_barrier() 在同一把 g_ctx_mtx 下发布 Refreshing 并等 g_active_decrypts 归零,所以「测状态」和「占槽位」不再是两个可以错开的步骤。槽位只包住一次解密调用、中间没有 socket I/O,排空是微秒级;超时(5s)就推迟这轮 refresh 并重试,而不是在一个卡住的库调用底下 reset。
  • generation 计数器 —— 在 kd_context_mutex 下紧挨着 resetAllContexts() 递增。worker 在同一把锁下把 context 和 generation 一起取走,所以两者不可能跨越一次 reset;每个 sample 前重新比对一次。这管的正是 barrier 看不见的那类 worker:reset 之前拿到 context、reset 之后才回来。

槽位释放放在 ~DecryptSlot,理由和 run_with_mutex 完全一样——解密调用会抛,main.c 没有 landing pad,写在 C 侧的自减会在抛出路径上被跳过,恢复线程就会在一个永远不回来的槽位上把超时耗满。

sample 循环里那个 is_refreshing() 直接删掉了,没有保留成「快速路径」。留着一个看起来在保护、实际不保护的检查,正是这个 bug 的成因;现在 run_decrypt_guarded 是唯一的强制点,删除处留了注释说明为什么裸标志位不够。

2. 自激循环

refresh 成功后清空事件队列——队列里的事件都早于本轮 reset 完成,requestLease / resetAllContexts / 新 preshare context 全发生在它们之后,为它们再跑一轮只会把刚重建的 context 拆掉,而且成功已经把 consec_fails 清零、退避拦不住。另加 1 秒最小 refresh 间隔,独立于失败退避。

3. m3u8 字节数

改成 sizeof("\n"),和下面 8 行的取失败路径一致。

第 4 条没改,说明理由

decrypt 协议里没有错误帧:客户端发完请求就等着读回恰好 size 字节,插一个拒绝字节会让不认识它的客户端直接错位。按 AGENTS.md「不留兼容路径」,改线格式意味着四方要同步更新,超出本 PR 范围;而 EOF 本来就是 handle() 里其它所有失败路径的既有信号。Failed 期间照收请求是 PR 描述里写明的设计取舍,当时也是作为问题而非缺陷提出的,一并保留。

一个行为变化

recovery 完成后,仍持有旧 context 的 in-flight 解密会被拒绝并断开连接,客户端需要重新请求 key。这是有意的:拿刚 reset 过的 context 去重试同一个 sample,风险是静默解出错误数据,不如让它重连。日志里是 [.] decrypt aborted: context reset by lease recovery, client must re-request the key

复核过的并发性质

两把锁从不嵌套——worker 是 kd_context_mutex → 释放 → g_ctx_mtx,恢复线程是 g_ctx_mtx → 释放 → kd_context_mutex,所以不存在死锁。barrier 返回后到 resetAllContexts() 之间没有新槽位能被拿到,因为 acquire 仍看到 Refreshing。barrier 超时路径不做 reset,context 保持有效,退避后重试。refresh 期间到达的 worker 阻塞在 kd_context_mutex 上,refresh 完成后拿到的是新 context + 新 generation。


Generated by Claude Code

@LYJW131
LYJW131 merged commit 5722928 into main Aug 17, 2026
1 check passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 04ebae644f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread main.cpp
"this refresh\n",
stale);
std::queue<int> drained;
g_recovery_q.swap(drained);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Retain recovery events that arrive after the refresh

If endLeaseCb or pbErrCb enqueues a new failure after refresh_decrypt_ctx() finishes but before this queue is drained, that event is discarded even though it may describe the newly requested lease. The queue mutex only serializes access; it does not prove that every queued event predates the reset. The worker then transitions to Running without another retry, potentially leaving the daemon on a failed lease until an unrelated decrypt error schedules recovery, so only events captured before the refresh began should be treated as superseded.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants