concurrency.spec.ts 4.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  1. // Cross-instance and writer-lock behavior: two providers on one document are
  2. // the in-process equivalent of two dsh processes sharing a harness home —
  3. // neither knows the other's cache, so only the read-modify-write cycle under
  4. // the `<file>.lock` sibling keeps both namespaces alive on disk.
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import { Context } from 'cordis'
  7. import z from 'schemastery'
  8. import { chmod, mkdtemp, readFile, rm, utimes, writeFile } from 'node:fs/promises'
  9. import { tmpdir } from 'node:os'
  10. import { join } from 'node:path'
  11. import { settingsNamespace } from '@deepseek-ai/dsh-settings'
  12. import { SettingsLocal } from '../src/index.ts'
  13. const AlphaSchema: z<{ value: number }> = z.object({ value: z.number().default(0) })
  14. const BetaSchema: z<{ value: number }> = z.object({ value: z.number().default(0) })
  15. const cleanups: Array<() => Promise<void>> = []
  16. afterEach(async () => {
  17. while (cleanups.length > 0) await cleanups.pop()!()
  18. })
  19. async function tempDir(): Promise<string> {
  20. const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-lock-'))
  21. cleanups.push(() => rm(dir, { recursive: true, force: true }))
  22. return dir
  23. }
  24. async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Promise<Context> {
  25. const ctx = new Context()
  26. const fiber = ctx.plugin(SettingsLocal, config)
  27. cleanups.push(async () => { await fiber.dispose() })
  28. await fiber
  29. return ctx
  30. }
  31. describe('cross-instance writes', () => {
  32. it('keeps both namespaces when two providers write the same document concurrently', async () => {
  33. const dir = await tempDir()
  34. const path = join(dir, 'settings.yaml')
  35. const first = await boot({ path, watch: false })
  36. const second = await boot({ path, watch: false })
  37. const alpha = first.settings.register(settingsNamespace('alpha'), AlphaSchema)
  38. const beta = second.settings.register(settingsNamespace('beta'), BetaSchema)
  39. const rounds = [1, 2, 3, 4, 5]
  40. await Promise.all([
  41. (async () => { for (const value of rounds) await alpha.update({ value }) })(),
  42. (async () => { for (const value of rounds) await beta.update({ value }) })(),
  43. ])
  44. const text = await readFile(path, 'utf8')
  45. expect(text).toContain('alpha:')
  46. expect(text).toContain('beta:')
  47. // A third instance resolves both final values from the shared document.
  48. const third = await boot({ path, watch: false })
  49. expect(third.settings.register(settingsNamespace('alpha'), AlphaSchema).get()).toEqual({ value: 5 })
  50. expect(third.settings.register(settingsNamespace('beta'), BetaSchema).get()).toEqual({ value: 5 })
  51. })
  52. })
  53. describe('writer lock', () => {
  54. it('waits for a busy writer lock instead of failing', async () => {
  55. const dir = await tempDir()
  56. const path = join(dir, 'settings.yaml')
  57. const ctx = await boot({ path, watch: false })
  58. const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
  59. await writeFile(`${path}.lock`, 'holder\n')
  60. const release = setTimeout(() => { void rm(`${path}.lock`, { force: true }) }, 120)
  61. cleanups.push(async () => { clearTimeout(release) })
  62. await scope.update({ value: 7 })
  63. expect(await readFile(path, 'utf8')).toContain('value: 7')
  64. })
  65. it('does not steal an old writer lock', async () => {
  66. const dir = await tempDir()
  67. const path = join(dir, 'settings.yaml')
  68. await writeFile(path, 'alpha:\n value: 4\n')
  69. const ctx = await boot({ path, watch: false })
  70. const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
  71. const lockPath = `${path}.lock`
  72. await writeFile(lockPath, 'slow-holder\n')
  73. const past = (Date.now() - 60_000) / 1000
  74. await utimes(lockPath, past, past)
  75. await expect(scope.update({ value: 9 })).rejects.toThrow(/timed out waiting for the writer lock/)
  76. expect(await readFile(path, 'utf8')).toContain('value: 4')
  77. expect(await readFile(lockPath, 'utf8')).toBe('slow-holder\n')
  78. }, 10_000)
  79. it('surfaces a non-contention lock failure as the write error', async () => {
  80. const dir = await tempDir()
  81. const path = join(dir, 'settings.yaml')
  82. const ctx = await boot({ path, watch: false })
  83. const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
  84. await chmod(dir, 0o500)
  85. cleanups.push(() => chmod(dir, 0o700))
  86. await expect(scope.update({ value: 1 })).rejects.toThrow(/EACCES|permission/)
  87. })
  88. })