Skip to content

Commit dbfbab0

Browse files
authored
fix: cleanup temp files after error during write (#30)
* failing test for write error cleanup * cleanup temp files on write error during import * also test cleanup on close error
1 parent 142e10d commit dbfbab0

2 files changed

Lines changed: 101 additions & 3 deletions

File tree

src/context.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,21 @@ export class Context {
130130
const writer = writable.getWriter()
131131
return new WritableStream({
132132
async write(chunk) {
133-
await writer.write(chunk)
133+
try {
134+
await writer.write(chunk)
135+
} catch (err) {
136+
await fsPromises.unlink(tempPath).catch(noop)
137+
throw err
138+
}
134139
},
135140
close: async () => {
136141
// Finish writing to the temp file
137-
await writer.close()
142+
try {
143+
await writer.close()
144+
} catch (err) {
145+
await fsPromises.unlink(tempPath).catch(noop)
146+
throw err
147+
}
138148

139149
// Validate the uploaded map file BEFORE replacing the existing one
140150
const tempReader = new Reader(tempPath)

test/maps.test.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { setTimeout as delay } from 'node:timers/promises'
66
import { fileURLToPath } from 'node:url'
77

88
import { Reader } from 'styled-map-package'
9-
import { describe, it, expect } from 'vitest'
9+
import { describe, it, expect, vi } from 'vitest'
1010

1111
import {
1212
DEMOTILES_Z2,
@@ -570,6 +570,94 @@ describe('Map Upload', () => {
570570
expect(style).toEqual(expectedStyle)
571571
})
572572

573+
it('should clean up temp file when write errors during upload', async (t) => {
574+
const { localBaseUrl, customMapPath } = await startServer(t, {
575+
customMapPath: nonExistentPath,
576+
})
577+
const tmpDir = path.dirname(customMapPath)
578+
const customMapBasename = path.basename(customMapPath)
579+
580+
// Mock fs.createWriteStream to return a writable that errors on write,
581+
// simulating a disk-full scenario
582+
const originalCreateWriteStream = fs.createWriteStream
583+
const spy = vi.spyOn(fs, 'createWriteStream').mockImplementation(
584+
(...args: any[]) => {
585+
const stream = originalCreateWriteStream.apply(fs, args as any)
586+
stream._write = (
587+
_chunk: any,
588+
_encoding: BufferEncoding,
589+
callback: (error?: Error | null) => void,
590+
) => {
591+
callback(new Error('ENOSPC: no space left on device'))
592+
}
593+
return stream
594+
},
595+
)
596+
t.onTestFinished(() => spy.mockRestore())
597+
598+
const fileBuffer = fs.readFileSync(OSM_BRIGHT_Z6)
599+
const response = await fetch(`${localBaseUrl}/maps/custom`, {
600+
method: 'PUT',
601+
body: fileBuffer,
602+
headers: { 'Content-Type': 'application/octet-stream' },
603+
})
604+
605+
expect(response.status).toBe(500)
606+
607+
// Allow time for async cleanup to complete
608+
await delay(100)
609+
610+
// Verify temp file was cleaned up
611+
const filesInDir = fs.readdirSync(tmpDir)
612+
const tempFiles = filesInDir.filter(
613+
(f) => f.startsWith(customMapBasename) && f.includes('.download-'),
614+
)
615+
expect(tempFiles).toHaveLength(0)
616+
})
617+
618+
it('should clean up temp file when close errors during upload', async (t) => {
619+
const { localBaseUrl, customMapPath } = await startServer(t, {
620+
customMapPath: nonExistentPath,
621+
})
622+
const tmpDir = path.dirname(customMapPath)
623+
const customMapBasename = path.basename(customMapPath)
624+
625+
// Mock fs.createWriteStream to return a writable that writes successfully
626+
// but errors on close (_final), simulating a flush/sync failure
627+
const originalCreateWriteStream = fs.createWriteStream
628+
const spy = vi.spyOn(fs, 'createWriteStream').mockImplementation(
629+
(...args: any[]) => {
630+
const stream = originalCreateWriteStream.apply(fs, args as any)
631+
stream._final = (
632+
callback: (error?: Error | null) => void,
633+
) => {
634+
callback(new Error('EIO: i/o error'))
635+
}
636+
return stream
637+
},
638+
)
639+
t.onTestFinished(() => spy.mockRestore())
640+
641+
const fileBuffer = fs.readFileSync(OSM_BRIGHT_Z6)
642+
const response = await fetch(`${localBaseUrl}/maps/custom`, {
643+
method: 'PUT',
644+
body: fileBuffer,
645+
headers: { 'Content-Type': 'application/octet-stream' },
646+
})
647+
648+
expect(response.status).toBe(500)
649+
650+
// Allow time for async cleanup to complete
651+
await delay(100)
652+
653+
// Verify temp file was cleaned up
654+
const filesInDir = fs.readdirSync(tmpDir)
655+
const tempFiles = filesInDir.filter(
656+
(f) => f.startsWith(customMapBasename) && f.includes('.download-'),
657+
)
658+
expect(tempFiles).toHaveLength(0)
659+
})
660+
573661
it('should not leave temp files after successful upload', async (t) => {
574662
const { localBaseUrl, customMapPath } = await startServer(t, {
575663
customMapPath: nonExistentPath,

0 commit comments

Comments
 (0)