|
2 | 2 | ANDROID_EMULATOR, |
3 | 3 | deviceClaimFilePath, |
4 | 4 | createTestHostEnvironment, |
| 5 | + mkdtempForTestSync, |
5 | 6 | } from '@agent-device/test-kit'; |
6 | 7 | import { expect, test, vi } from 'vitest'; |
7 | 8 | import { |
@@ -467,6 +468,49 @@ test('a failed release abandons the claim so the next command is not locked out' |
467 | 468 | expect(inspectDeviceClaims({}, process.env)).toEqual([]); |
468 | 469 | }); |
469 | 470 |
|
| 471 | +test("a failed release abandons the claim in the host's claims directory, not the process's", async () => { |
| 472 | + const { stateDir } = setup(); |
| 473 | + const hostClaimsDir = mkdtempForTestSync('agent-device-host-claims-'); |
| 474 | + const hostEnv = { ...process.env, AGENT_DEVICE_CLAIMS_DIR: hostClaimsDir }; |
| 475 | + const admission = createDeviceClaimAdmission({ |
| 476 | + env: hostEnv, |
| 477 | + policy: 'transient-exclusive', |
| 478 | + command: 'install', |
| 479 | + session: 'own-session', |
| 480 | + workspace: '/worktrees/current', |
| 481 | + callerCwd: '/worktrees/current', |
| 482 | + resolveScopeRoot: resolveCallerScopeRoot, |
| 483 | + stateDir, |
| 484 | + reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, |
| 485 | + }); |
| 486 | + await admission.admit(ANDROID_EMULATOR, localAndroid); |
| 487 | + const claimFiles = fs |
| 488 | + .readdirSync(hostClaimsDir, { recursive: true }) |
| 489 | + .map((entry) => path.join(hostClaimsDir, String(entry))) |
| 490 | + .filter((entry) => fs.statSync(entry).isFile() && entry.endsWith('.json')); |
| 491 | + expect(claimFiles).toHaveLength(1); |
| 492 | + |
| 493 | + const originalUnlink = fs.unlinkSync; |
| 494 | + const unlink = vi.spyOn(fs, 'unlinkSync').mockImplementation((target) => { |
| 495 | + if (String(target) === claimFiles[0]) { |
| 496 | + throw Object.assign(new Error('read-only file system'), { code: 'EROFS' }); |
| 497 | + } |
| 498 | + return originalUnlink(target as Parameters<typeof fs.unlinkSync>[0]); |
| 499 | + }); |
| 500 | + try { |
| 501 | + await admission[Symbol.asyncDispose](); |
| 502 | + } finally { |
| 503 | + unlink.mockRestore(); |
| 504 | + } |
| 505 | + |
| 506 | + // The abandon went to the host's directory, where the claim was written; the process's own |
| 507 | + // claims directory never held it. |
| 508 | + const survivor = inspectDeviceClaims({}, hostEnv); |
| 509 | + expect(survivor.map((entry) => entry.claim?.session)).toEqual(['transient:install']); |
| 510 | + expect(survivor[0]?.claim?.abandonedAtMs).toBeTypeOf('number'); |
| 511 | + expect(inspectDeviceClaims({}, process.env)).toEqual([]); |
| 512 | +}); |
| 513 | + |
470 | 514 | test("an abandoned claim becomes this command's own transient claim and is released", async () => { |
471 | 515 | const { stateDir } = setup(); |
472 | 516 | const aborted = await acquireDeviceClaim({ |
|
0 commit comments