Skip to content

Commit 18fc377

Browse files
fix(cli): keep new downloads atomic
1 parent ab22e0d commit 18fc377

1 file changed

Lines changed: 27 additions & 27 deletions

File tree

packages/sim-cli/src/commands/protocol/files-get.ts

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { once } from 'node:events'
22
import { createWriteStream, type WriteStream } from 'node:fs'
3-
import { lstat, mkdtemp, open, readlink, rename, rm } from 'node:fs/promises'
3+
import { link, lstat, mkdtemp, readlink, rename, rm } from 'node:fs/promises'
44
import { dirname, join, resolve } from 'node:path'
55
import { Readable, type Writable } from 'node:stream'
66
import { pipeline } from 'node:stream/promises'
@@ -60,6 +60,15 @@ function combinedCleanupFailure(
6060
)
6161
}
6262

63+
function unsupportedAtomicPublish(target: string, error: unknown): SimApiError | null {
64+
const code = (error as NodeJS.ErrnoException).code
65+
if (!['ENOSYS', 'ENOTSUP', 'EOPNOTSUPP', 'EPERM'].includes(code ?? '')) return null
66+
return new SimApiError(
67+
`Could not publish ${target} without overwrite protection because this filesystem does not support atomic hard links. Re-run with --force to publish the completed download with an atomic rename.`,
68+
0
69+
)
70+
}
71+
6372
/** Streams a fetch body to disk while honoring write-stream backpressure. */
6473
export async function streamToFile(
6574
body: ReadableStream<Uint8Array>,
@@ -73,36 +82,28 @@ export async function streamToFile(
7382
}
7483
}
7584

76-
async function saveNewFile(body: ReadableStream<Uint8Array>, target: string): Promise<void> {
77-
let created = false
78-
79-
try {
80-
const file = await open(target, 'wx')
81-
created = true
82-
await streamToFile(body, file.createWriteStream(), target)
83-
} catch (error) {
84-
const failure = normalizedWriteFailure(target, error)
85-
if (!created) throw failure
86-
87-
try {
88-
await rm(target, { force: true })
89-
} catch (cleanupError) {
90-
throw combinedCleanupFailure(failure, target, cleanupError)
91-
}
92-
throw failure
93-
}
94-
}
95-
96-
async function saveForcedFile(body: ReadableStream<Uint8Array>, target: string): Promise<void> {
85+
async function saveStagedFile(
86+
body: ReadableStream<Uint8Array>,
87+
target: string,
88+
force: boolean
89+
): Promise<void> {
9790
let temporaryDirectory: string | null = null
9891
let failure: SimApiError | null = null
9992

10093
try {
101-
const publicationTarget = await forcedPublicationTarget(target)
94+
const publicationTarget = force ? await forcedPublicationTarget(target) : target
10295
temporaryDirectory = await mkdtemp(join(dirname(publicationTarget), '.sim-download-'))
10396
const temporaryPath = join(temporaryDirectory, 'payload')
10497
await streamToFile(body, createWriteStream(temporaryPath, { flags: 'wx' }), target)
105-
await rename(temporaryPath, publicationTarget)
98+
if (force) {
99+
await rename(temporaryPath, publicationTarget)
100+
} else {
101+
try {
102+
await link(temporaryPath, publicationTarget)
103+
} catch (error) {
104+
throw unsupportedAtomicPublish(target, error) ?? error
105+
}
106+
}
106107
} catch (error) {
107108
failure = normalizedWriteFailure(target, error)
108109
}
@@ -122,14 +123,13 @@ async function saveForcedFile(body: ReadableStream<Uint8Array>, target: string):
122123
if (failure) throw failure
123124
}
124125

125-
/** Saves without overwriting by default; forced writes publish only after the body is complete. */
126+
/** Publishes a complete staged body atomically, with overwrite requiring explicit force. */
126127
export async function saveToFile(
127128
body: ReadableStream<Uint8Array>,
128129
target: string,
129130
force: boolean
130131
): Promise<void> {
131-
if (force) return saveForcedFile(body, target)
132-
return saveNewFile(body, target)
132+
return saveStagedFile(body, target, force)
133133
}
134134

135135
/** Streams a fetch body to stdout without closing the process-wide stream. */

0 commit comments

Comments
 (0)