lease.two-process.e2e.ts 2.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
  1. /**
  2. * Real two-process lock contention over one shared root: a child Node
  3. * process (running the built package under plain Node) creates a session and
  4. * holds its kernel write lock; this process is excluded while the child
  5. * lives, and acquires immediately after a SIGKILL — the kernel releases the
  6. * lock with the dead process's descriptors, no waiting period. Keyless.
  7. */
  8. import { spawn } from 'node:child_process'
  9. import { once } from 'node:events'
  10. import { mkdtemp, rm } from 'node:fs/promises'
  11. import { tmpdir } from 'node:os'
  12. import { join } from 'node:path'
  13. import { fileURLToPath } from 'node:url'
  14. import { afterEach, describe, expect, it } from 'vitest'
  15. import { Context } from '@deepseek-ai/cordis'
  16. import { SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
  17. import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence'
  18. import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
  19. const SESSION = 'two-process-lease'
  20. const dirs: string[] = []
  21. const contexts: Context[] = []
  22. afterEach(async () => {
  23. for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
  24. for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true })
  25. })
  26. const HOLDER = fileURLToPath(new URL('./fixtures/lease-holder.mjs', import.meta.url))
  27. describe('two-process write lock (built lib)', () => {
  28. it('excludes a live holder process and takes over immediately after its crash', { timeout: 30_000 }, async () => {
  29. const root = await mkdtemp(join(tmpdir(), 'dsh-lease-2proc-'))
  30. dirs.push(root)
  31. const holder = spawn(process.execPath, [HOLDER, root, SESSION], {
  32. stdio: ['ignore', 'pipe', 'inherit'],
  33. })
  34. const exited = new Promise<void>((resolve) => { holder.once('exit', () => { resolve() }) })
  35. try {
  36. await once(holder.stdout, 'data') // 'holding'
  37. const ctx = new Context()
  38. contexts.push(ctx)
  39. await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
  40. const mine = ctx.sessionPersistence
  41. // Excluded while the other process's descriptor holds the kernel lock.
  42. await expect(mine.open(SessionId(SESSION), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError)
  43. // Reads are unaffected across processes.
  44. const reader = await mine.open(SessionId(SESSION), 'read')
  45. expect((await reader.read()).events.map(event => event.seq)).toEqual([0, 1])
  46. await reader.close()
  47. // Crash the holder: no release runs, but the kernel drops the lock with
  48. // the process, so takeover succeeds without any waiting period.
  49. holder.kill('SIGKILL')
  50. await exited
  51. const taken = await mine.open(SessionId(SESSION), 'write')
  52. await taken.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }])
  53. expect((await taken.read()).events.map(event => event.seq)).toEqual([0, 1, 2])
  54. await taken.close()
  55. } finally {
  56. if (holder.exitCode === null) holder.kill('SIGKILL')
  57. }
  58. })
  59. })