Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,21 @@ export class Context {
const writer = writable.getWriter()
return new WritableStream({
async write(chunk) {
await writer.write(chunk)
try {
await writer.write(chunk)
} catch (err) {
await fsPromises.unlink(tempPath).catch(noop)
throw err
}
},
close: async () => {
// Finish writing to the temp file
await writer.close()
try {
await writer.close()
} catch (err) {
await fsPromises.unlink(tempPath).catch(noop)
throw err
}

// Validate the uploaded map file BEFORE replacing the existing one
const tempReader = new Reader(tempPath)
Expand Down
90 changes: 89 additions & 1 deletion test/maps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { setTimeout as delay } from 'node:timers/promises'
import { fileURLToPath } from 'node:url'

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

import {
DEMOTILES_Z2,
Expand Down Expand Up @@ -570,6 +570,94 @@ describe('Map Upload', () => {
expect(style).toEqual(expectedStyle)
})

it('should clean up temp file when write errors during upload', async (t) => {
const { localBaseUrl, customMapPath } = await startServer(t, {
customMapPath: nonExistentPath,
})
const tmpDir = path.dirname(customMapPath)
const customMapBasename = path.basename(customMapPath)

// Mock fs.createWriteStream to return a writable that errors on write,
// simulating a disk-full scenario
const originalCreateWriteStream = fs.createWriteStream
const spy = vi.spyOn(fs, 'createWriteStream').mockImplementation(
(...args: any[]) => {
const stream = originalCreateWriteStream.apply(fs, args as any)
stream._write = (
_chunk: any,
_encoding: BufferEncoding,
callback: (error?: Error | null) => void,
) => {
callback(new Error('ENOSPC: no space left on device'))
}
return stream
},
)
t.onTestFinished(() => spy.mockRestore())

const fileBuffer = fs.readFileSync(OSM_BRIGHT_Z6)
const response = await fetch(`${localBaseUrl}/maps/custom`, {
method: 'PUT',
body: fileBuffer,
headers: { 'Content-Type': 'application/octet-stream' },
})

expect(response.status).toBe(500)

// Allow time for async cleanup to complete
await delay(100)

// Verify temp file was cleaned up
const filesInDir = fs.readdirSync(tmpDir)
const tempFiles = filesInDir.filter(
(f) => f.startsWith(customMapBasename) && f.includes('.download-'),
)
expect(tempFiles).toHaveLength(0)
})

it('should clean up temp file when close errors during upload', async (t) => {
const { localBaseUrl, customMapPath } = await startServer(t, {
customMapPath: nonExistentPath,
})
const tmpDir = path.dirname(customMapPath)
const customMapBasename = path.basename(customMapPath)

// Mock fs.createWriteStream to return a writable that writes successfully
// but errors on close (_final), simulating a flush/sync failure
const originalCreateWriteStream = fs.createWriteStream
const spy = vi.spyOn(fs, 'createWriteStream').mockImplementation(
(...args: any[]) => {
const stream = originalCreateWriteStream.apply(fs, args as any)
stream._final = (
callback: (error?: Error | null) => void,
) => {
callback(new Error('EIO: i/o error'))
}
return stream
},
)
t.onTestFinished(() => spy.mockRestore())

const fileBuffer = fs.readFileSync(OSM_BRIGHT_Z6)
const response = await fetch(`${localBaseUrl}/maps/custom`, {
method: 'PUT',
body: fileBuffer,
headers: { 'Content-Type': 'application/octet-stream' },
})

expect(response.status).toBe(500)

// Allow time for async cleanup to complete
await delay(100)

// Verify temp file was cleaned up
const filesInDir = fs.readdirSync(tmpDir)
const tempFiles = filesInDir.filter(
(f) => f.startsWith(customMapBasename) && f.includes('.download-'),
)
expect(tempFiles).toHaveLength(0)
})

it('should not leave temp files after successful upload', async (t) => {
const { localBaseUrl, customMapPath } = await startServer(t, {
customMapPath: nonExistentPath,
Expand Down