Skip to content
Closed
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
15 changes: 15 additions & 0 deletions src/pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,21 @@ export class Pack
return
}

// Sockets, FIFOs, and device nodes are not packed (GNU tar skips
// sockets). WriteEntry treats them as Unsupported and ends with no
// header, so 'end' fires while the job is still read-ahead.
// JOBDONE always shift()s the queue head, which then drops the
// current file and stalls create() when two such entries follow a
// regular file (#295).
if (
!job.stat.isFile() &&
!job.stat.isDirectory() &&
!job.stat.isSymbolicLink()
) {
job.ignore = true
return
}

if (!this.noDirRecurse && job.stat.isDirectory() && !job.readdir) {
const rc = this.readdirCache.get(job.absolute)
if (rc) {
Expand Down
92 changes: 92 additions & 0 deletions test/create.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import net from 'node:net'
import os from 'node:os'
import { mkdirp } from 'mkdirp'
import path from 'node:path'
import { rimraf } from 'rimraf'
Expand Down Expand Up @@ -363,3 +365,93 @@ t.test('transform a filename', async t => {
).concat()
t.equal(data.subarray(0, 'bloorg.md'.length).toString(), 'bloorg.md')
})

const listenUnix = (sockPath: string) =>
new Promise<net.Server>((resolve, reject) => {
try {
fs.unlinkSync(sockPath)
} catch {}
const server = net.createServer()
server.on('error', reject)
server.listen(sockPath, () => resolve(server))
})

const listPaths = (file: string) => {
const found: string[] = []
list({
file,
sync: true,
onReadEntry: entry => {
found.push(entry.path)
entry.resume()
},
})
return found
}

// File-first + two sockets is the hang: WriteEntry ends unsupported
// types with no header, JOBDONE shift()s the queue head, and create()
// never finishes. One socket after a file does not stall.
t.test(
'create skips unix sockets and does not hang (#295)',
{
skip: isWindows && 'unix sockets',
timeout: 10000,
},
async t => {
const expect = ['./', './aaa.txt']

const check = (t: Test, file: string) => {
const found = listPaths(file)
t.strictSame([...found].sort(), [...expect].sort())
t.notOk(
found.some(p => p.endsWith('.sock')),
'sockets are not archived',
)
}

const setupTree = async () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'node-tar-295-'))
fs.writeFileSync(path.join(cwd, 'aaa.txt'), 'hello\n')
const servers = await Promise.all([
listenUnix(path.join(cwd, 'b.sock')),
listenUnix(path.join(cwd, 'c.sock')),
])
t.teardown(() => {
for (const server of servers) {
server.close()
}
fs.rmSync(cwd, { recursive: true, force: true })
})
return cwd
}

t.test('async file', async t => {
const cwd = await setupTree()
const file = path.resolve(dir, 'sockets-async.tar')
await c({ file, cwd }, ['.'])
check(t, file)
})

t.test('sync file', async t => {
const cwd = await setupTree()
const file = path.resolve(dir, 'sockets-sync.tar')
c({ file, cwd, sync: true }, ['.'])
check(t, file)
})

t.test('gzip file (issue reproduction)', async t => {
const cwd = await setupTree()
const file = path.resolve(dir, 'sockets.tar.gz')
await c({ file, cwd, gzip: true }, ['.'])
check(t, file)
})

t.test('socket as sole entry', async t => {
const cwd = await setupTree()
const file = path.resolve(dir, 'socket-only.tar')
await c({ file, cwd }, ['b.sock'])
t.strictSame(listPaths(file), [])
})
},
)
86 changes: 86 additions & 0 deletions test/pack.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import t from 'tap'
import { Pack, PackSync } from '../dist/esm/pack.js'
import fs from 'fs'
import net from 'net'
import path, { resolve } from 'path'
import { fileURLToPath } from 'url'
import { Parser } from '../src/parse.js'
Expand Down Expand Up @@ -1959,3 +1960,88 @@ t.test('avoid permanent link deferral', async t => {
new Set(['pkgB/index.js', 'pkgB/foo.js', 'pkgB/dist/index.js']),
)
})

t.test(
'skips sockets so a later file still packs (#295)',
{ skip: isWindows && 'unix sockets', timeout: 8000 },
async t => {
const cwd = t.testdir({
'aaa.txt': 'hello\n',
})
const servers = await Promise.all(
['b.sock', 'c.sock'].map(
name =>
new Promise((resolve, reject) => {
const sock = path.join(cwd, name)
try {
fs.unlinkSync(sock)
} catch {}
const server = net.createServer()
server.on('error', reject)
server.listen(sock, () => resolve(server))
}),
),
)
t.teardown(() => {
for (const server of servers) {
server.close()
}
})

const namesFrom = data => {
const names = []
for (let i = 0; i + 512 <= data.length; ) {
const block = data.subarray(i, i + 512)
if (block.every(b => b === 0)) {
break
}
const h = new Header(block)
if (h.path) {
names.push(h.path)
}
i += 512 + 512 * Math.ceil((h.size || 0) / 512)
}
return names
}

// Delay lstat of the regular file so the two sockets are processed
// as read-ahead while the file is still the queue head. On unfixed
// Pack, their empty WriteEntry 'end' makes JOBDONE shift() the file
// off the queue and aaa.txt never gets packed.
const { Pack: SlowPack } = await t.mockImport('../src/pack.js', {
fs: t.createMock(fs, {
readdir: (p, cb) => cb(null, ['aaa.txt', 'b.sock', 'c.sock']),
lstat: (p, cb) => {
if (String(p).includes('aaa.txt')) {
setTimeout(() => fs.lstat(p, cb), 100)
} else {
fs.lstat(p, cb)
}
},
}),
})

const out = []
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Pack hung')), 4000)
new SlowPack({ cwd })
.end('.')
.on('data', c => out.push(c))
.on('end', () => {
clearTimeout(timer)
resolve()
})
.on('error', reject)
})
t.strictSame(namesFrom(Buffer.concat(out)).sort(), ['./', './aaa.txt'])

const sync = new PackSync({ cwd })
const sout = []
sync.on('data', c => sout.push(c))
sync.end('.')
t.strictSame(namesFrom(Buffer.concat(sout)).sort(), [
'./',
'./aaa.txt',
])
},
)