|
1 | 1 | import * as path from 'path'; |
2 | 2 | import test from 'ava'; |
3 | 3 | import * as fs from 'fs/promises'; |
4 | | -import {atomicWriteFile} from './atomic-write'; |
| 4 | +import {atomicWriteFile, publishFileNoClobber, setLinkSyncForTests} from './atomic-write'; |
5 | 5 |
|
6 | 6 | async function createTempDir(): Promise<string> { |
7 | 7 | const tempDir = path.join( |
@@ -78,3 +78,71 @@ test.serial('atomicWriteFile leaves no temp file behind on failure', async t => |
78 | 78 | await cleanupTempDir(tempDir); |
79 | 79 | } |
80 | 80 | }); |
| 81 | + |
| 82 | +test.serial('publishFileNoClobber publishes when the destination is absent', async t => { |
| 83 | + const tempDir = await createTempDir(); |
| 84 | + try { |
| 85 | + const filePath = path.join(tempDir, 'usage.json'); |
| 86 | + |
| 87 | + t.true(publishFileNoClobber(filePath, '{"totalLifetime": 5}')); |
| 88 | + t.is(await fs.readFile(filePath, 'utf-8'), '{"totalLifetime": 5}'); |
| 89 | + // No temp litter left behind. |
| 90 | + const dirListing = await fs.readdir(tempDir); |
| 91 | + t.false(dirListing.some(name => name.includes('.tmp'))); |
| 92 | + } finally { |
| 93 | + await cleanupTempDir(tempDir); |
| 94 | + } |
| 95 | +}); |
| 96 | + |
| 97 | +test.serial('publishFileNoClobber keeps the existing file when a peer won the race', async t => { |
| 98 | + // The concurrent-publish window (destination appearing between the |
| 99 | + // exists check and the publish) cannot be staged single-threaded — |
| 100 | + // link(2) atomicity is the arbiter — so this covers the seam where the |
| 101 | + // old and new code differ: it fails against a copy-based implementation |
| 102 | + // (verified by mutation) and passes against the link-based one. |
| 103 | + const tempDir = await createTempDir(); |
| 104 | + try { |
| 105 | + const filePath = path.join(tempDir, 'usage.json'); |
| 106 | + await fs.writeFile(filePath, 'winner-data', 'utf-8'); |
| 107 | + |
| 108 | + t.false(publishFileNoClobber(filePath, 'stale-data')); |
| 109 | + t.is(await fs.readFile(filePath, 'utf-8'), 'winner-data'); |
| 110 | + const dirListing = await fs.readdir(tempDir); |
| 111 | + t.false(dirListing.some(name => name.includes('.tmp'))); |
| 112 | + } finally { |
| 113 | + await cleanupTempDir(tempDir); |
| 114 | + } |
| 115 | +}); |
| 116 | + |
| 117 | +test.serial('publishFileNoClobber falls back to exclusive write when hard links are unsupported', async t => { |
| 118 | + // Simulate FAT/exFAT or SMB/FUSE mounts where linkSync throws EPERM. |
| 119 | + const linkError = new Error( |
| 120 | + 'operation not permitted, link', |
| 121 | + ) as NodeJS.ErrnoException; |
| 122 | + linkError.code = 'EPERM'; |
| 123 | + setLinkSyncForTests(() => { |
| 124 | + throw linkError; |
| 125 | + }); |
| 126 | + try { |
| 127 | + const tempDir = await createTempDir(); |
| 128 | + try { |
| 129 | + const filePath = path.join(tempDir, 'usage.json'); |
| 130 | + |
| 131 | + // Absent destination: the exclusive-create fallback publishes. |
| 132 | + t.true(publishFileNoClobber(filePath, 'fallback-data')); |
| 133 | + t.is(await fs.readFile(filePath, 'utf-8'), 'fallback-data'); |
| 134 | + |
| 135 | + // Present destination: EEXIST from the fallback likewise means a |
| 136 | + // peer won — content stays untouched. |
| 137 | + t.false(publishFileNoClobber(filePath, 'stale-data')); |
| 138 | + t.is(await fs.readFile(filePath, 'utf-8'), 'fallback-data'); |
| 139 | + |
| 140 | + const dirListing = await fs.readdir(tempDir); |
| 141 | + t.false(dirListing.some(name => name.includes('.tmp'))); |
| 142 | + } finally { |
| 143 | + await cleanupTempDir(tempDir); |
| 144 | + } |
| 145 | + } finally { |
| 146 | + setLinkSyncForTests(); |
| 147 | + } |
| 148 | +}); |
0 commit comments