diff --git a/src/pack.ts b/src/pack.ts index ad73357e..9f635ed8 100644 --- a/src/pack.ts +++ b/src/pack.ts @@ -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) { diff --git a/test/create.ts b/test/create.ts index ee634344..db9746bb 100644 --- a/test/create.ts +++ b/test/create.ts @@ -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' @@ -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((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), []) + }) + }, +) diff --git a/test/pack.js b/test/pack.js index e03708d9..00e324a4 100644 --- a/test/pack.js +++ b/test/pack.js @@ -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' @@ -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', + ]) + }, +)