Sfoglia il codice sorgente

test(session): observe shared waiter admission before cancellation

Tianyi Cui 2 settimane fa
parent
commit
a1d11f213f

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md
-2026-09-05-read-only-session-migration-preparation.md: c343ca457184554f4b47a0795dcb33b8b07e9d39
-2026-09-05-read-only-session-migration-preparation.zh.md: 3a283259729eda6a01fa4208cac5399f45978fac
+2026-09-05-read-only-session-migration-preparation.md: ab23e7e61dcdf0762cae6185de5fd16c4070fcbf
+2026-09-05-read-only-session-migration-preparation.zh.md: 89377d4d84776bebbc6d2ca6acea92ac15f74df3

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md

@@ -59,7 +59,7 @@ interface MigrationPreparation {
 }
 ```
 
-A new read or write open joins the existing entry only when its source path and revision still match. `waitWithAbort()` races each caller's AbortSignal against the shared Promise without forwarding that signal to shared work. The backend-owned controller is aborted only when the last waiter leaves while preparation is still running.
+A new read or write open joins the existing entry only when its source path and revision still match. `waitWithAbort()` races each caller's AbortSignal against the shared Promise without forwarding that signal to shared work. The backend-owned controller is aborted only when the last waiter leaves while preparation is still running. The cancellation test pauses the physical read and observes two registered waiters before aborting one caller; an event-loop yield alone cannot establish admission after asynchronous path and revision lookup.
 
 Completed results enter the existing bounded `coldLogMemo`. The `StoredLog` discriminant separates published current state from `PreparedStoredLog`, whose `publication` field binds current logical events to their matching publication operation. A query followed by Agent resume therefore reuses the same Decode and migration result. The in-flight map owns only running work; it is not a second completed-result cache.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md

@@ -59,7 +59,7 @@ interface MigrationPreparation {
 }
 ```
 
-新的 read/write open 只有在 source path 与 revision 仍匹配时才加入已有 entry。`waitWithAbort()` 让每个 caller 的 AbortSignal 与 shared Promise 竞争,但不会把 caller signal 传给共享工作。只有最后一个 waiter 在 preparation 仍运行时离开,backend-owned controller 才会 abort。
+新的 read/write open 只有在 source path 与 revision 仍匹配时才加入已有 entry。`waitWithAbort()` 让每个 caller 的 AbortSignal 与 shared Promise 竞争,但不会把 caller signal 传给共享工作。只有最后一个 waiter 在 preparation 仍运行时离开,backend-owned controller 才会 abort。取消测试暂停物理读取,并在取消一个 caller 前观察到两个已注册的 waiter;仅让出一次事件循环不能证明异步路径与 revision 查找后的加入已经完成。
 
 完成结果进入既有 bounded `coldLogMemo`。`StoredLog` 判别字段把已发布 current state 与 `PreparedStoredLog` 分开,后者的 `publication` 字段把 current logical events 与匹配的 publication operation 绑定,使 query 后紧接的 Agent resume 复用同一次 Decode 与 migration。In-flight map 只拥有运行中的工作,不是第二个 completed-result cache。
 

+ 21 - 9
packages/session/session-persistence-jsonl/tests/jsonl.spec.ts

@@ -793,17 +793,29 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
     const controller = new AbortController()
     const reason = new Error('first historical waiter cancelled')
 
+    const internals = ctx.sessionPersistence as unknown as {
+      migrationPreparations: Map<SessionId, { waiters: number }>
+    }
     const first = ctx.sessionPersistence.open(header.id, 'read', { signal: controller.signal })
     const second = ctx.sessionPersistence.open(header.id, 'read')
-    await pause.entered
-    await scheduler.yield()
-    controller.abort(reason)
-    await expect(first).rejects.toBe(reason)
-    pause.release()
-    const handle = await second
-    expect((await handle.read()).events).toEqual([])
-    expect(readTally.bySuffix.get(sourcePath)).toBe(1)
-    await handle.close()
+    const settled = Promise.allSettled([first, second])
+    try {
+      await pause.entered
+      // Both callers must join the preparation before either caller leaves it.
+      await expect.poll(() => internals.migrationPreparations.get(header.id)?.waiters).toBe(2)
+      controller.abort(reason)
+      await expect(first).rejects.toBe(reason)
+      pause.release()
+      const handle = await second
+      expect((await handle.read()).events).toEqual([])
+      expect(readTally.bySuffix.get(sourcePath)).toBe(1)
+    } finally {
+      controller.abort(reason)
+      pause.release()
+      for (const result of await settled) {
+        if (result.status === 'fulfilled') await result.value.close()
+      }
+    }
   })
 
   it('cancels shared historical preparation after its last waiter leaves', async () => {