From 1cfde5ad7fc379ea09f4442d6a7a2e76095cf75f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 18:38:50 +0000 Subject: [PATCH 1/2] pack: skip sockets so create does not hang WriteEntry treats sockets, FIFOs, and devices as Unsupported and ends with no header. That completion can run while the job is still read-ahead, and JOBDONE always shift()s the queue head, so two such entries after a regular file stall tar.create. Skip those types in Pack, matching GNU tar. Fixes #295 Co-authored-by: David --- src/pack.ts | 15 +++++++++ test/create.ts | 89 +++++++++++++++++++++++++++++++++++++++++++++++++- test/pack.js | 62 +++++++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 1 deletion(-) 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..ec80212a 100644 --- a/test/create.ts +++ b/test/create.ts @@ -1,12 +1,13 @@ import fs from 'node:fs' import fsp from 'node:fs/promises' +import net from 'node:net' import { mkdirp } from 'mkdirp' import path from 'node:path' import { rimraf } from 'rimraf' import type { Test } from 'tap' import t from 'tap' import { c, list, Pack, PackSync } from '../dist/esm/index.js' -import { spawn } from 'child_process' +import { execFileSync, spawn } from 'child_process' import { fileURLToPath } from 'url' const isWindows = process.platform === 'win32' @@ -363,3 +364,89 @@ 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) => { + 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: 8000, + }, + async t => { + const cwd = t.testdir({ + 'aaa.txt': 'hello\n', + sub: { + 'keep.txt': 'keep\n', + }, + }) + const servers = await Promise.all([ + listenUnix(path.join(cwd, 'b.sock')), + listenUnix(path.join(cwd, 'c.sock')), + listenUnix(path.join(cwd, 'sub', 'z.sock')), + ]) + t.teardown(() => { + for (const server of servers) { + server.close() + } + }) + execFileSync('mkfifo', [path.join(cwd, 'd.fifo')]) + + const expect = ['./', './aaa.txt', './sub/', './sub/keep.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') || p.endsWith('.fifo')), + 'special files are not archived', + ) + } + + t.test('async file', async t => { + const file = path.resolve(dir, 'sockets-async.tar') + await c({ file, cwd }, ['.']) + check(t, file) + }) + + t.test('sync file', t => { + const file = path.resolve(dir, 'sockets-sync.tar') + c({ file, cwd, sync: true }, ['.']) + check(t, file) + t.end() + }) + + t.test('gzip file (issue reproduction)', async t => { + 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 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..991f6294 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,64 @@ 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 server = net.createServer() + server.on('error', reject) + server.listen(path.join(cwd, name), () => 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 name = new Header(block).path + const size = new Header(block).size || 0 + if (name) { + names.push(name) + } + i += 512 + 512 * Math.ceil(size / 512) + } + return names + } + + const out = [] + await new Promise((resolve, reject) => { + new Pack({ cwd }) + .end('.') + .on('data', c => out.push(c)) + .on('end', 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', + ]) + }, +) From 50e9cae531d9336febac8546ac3c7a399a43bf6a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 18:46:49 +0000 Subject: [PATCH 2/2] test: make the socket pack hang deterministic Delay lstat of the regular file so two sockets are processed as read-ahead while it is still the queue head. That is the #295 corruption: JOBDONE shift()s the file off the queue and Pack never ends. The new pack.js test hangs on 2a22bfc and passes with the skip. Co-authored-by: David --- test/create.ts | 55 +++++++++++++++++++++++++++----------------------- test/pack.js | 40 ++++++++++++++++++++++++++++-------- 2 files changed, 62 insertions(+), 33 deletions(-) diff --git a/test/create.ts b/test/create.ts index ec80212a..db9746bb 100644 --- a/test/create.ts +++ b/test/create.ts @@ -1,13 +1,14 @@ 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' import type { Test } from 'tap' import t from 'tap' import { c, list, Pack, PackSync } from '../dist/esm/index.js' -import { execFileSync, spawn } from 'child_process' +import { spawn } from 'child_process' import { fileURLToPath } from 'url' const isWindows = process.platform === 'win32' @@ -367,6 +368,9 @@ t.test('transform a filename', async t => { 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)) @@ -392,58 +396,59 @@ t.test( 'create skips unix sockets and does not hang (#295)', { skip: isWindows && 'unix sockets', - timeout: 8000, + timeout: 10000, }, async t => { - const cwd = t.testdir({ - 'aaa.txt': 'hello\n', - sub: { - 'keep.txt': 'keep\n', - }, - }) - const servers = await Promise.all([ - listenUnix(path.join(cwd, 'b.sock')), - listenUnix(path.join(cwd, 'c.sock')), - listenUnix(path.join(cwd, 'sub', 'z.sock')), - ]) - t.teardown(() => { - for (const server of servers) { - server.close() - } - }) - execFileSync('mkfifo', [path.join(cwd, 'd.fifo')]) - - const expect = ['./', './aaa.txt', './sub/', './sub/keep.txt'] + 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') || p.endsWith('.fifo')), - 'special files are not archived', + 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', t => { + 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.end() }) 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 991f6294..00e324a4 100644 --- a/test/pack.js +++ b/test/pack.js @@ -1972,9 +1972,13 @@ t.test( ['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(path.join(cwd, name), () => resolve(server)) + server.listen(sock, () => resolve(server)) }), ), ) @@ -1991,22 +1995,42 @@ t.test( if (block.every(b => b === 0)) { break } - const name = new Header(block).path - const size = new Header(block).size || 0 - if (name) { - names.push(name) + const h = new Header(block) + if (h.path) { + names.push(h.path) } - i += 512 + 512 * Math.ceil(size / 512) + 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) => { - new Pack({ cwd }) + const timer = setTimeout(() => reject(new Error('Pack hung')), 4000) + new SlowPack({ cwd }) .end('.') .on('data', c => out.push(c)) - .on('end', resolve) + .on('end', () => { + clearTimeout(timer) + resolve() + }) .on('error', reject) }) t.strictSame(namesFrom(Buffer.concat(out)).sort(), ['./', './aaa.txt'])