Repository navigation
fix: keep packing the queue head when the job table is full - #461
official-burak wants to merge 1 commit into
Conversation
Hardlinks in later subdirs can fill the read-ahead table while the head is still pending. PROCESS then skipped the head, so nothing was piped, JOBS never dropped, and async pack/create hung until the event loop drained (exit 0 with a truncated archive). Fixes isaacs#460
|
Thanks for picking this up. I tested this branch against a hang we hit in CI, and it fixes the #458 repro but not ours: it still deadlocks once more than Our trigger was an ordinary Bun install on Linux: Bun hardlinks every installed file from its cache, and its hoisted layout installs the same package at several nested paths, so the file list contains several paths per inode. Reduced to N files followed by N hardlinks to them: // npm i tar@7.5.22 && node repro.mjs [pairs=5]
// N files, then N hardlinks to them later in the list.
import { linkSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import * as tar from 'tar'
const pairs = Number(process.argv[2] ?? 5)
const cwd = mkdtempSync(join(tmpdir(), 'tar-links-'))
mkdirSync(join(cwd, 'a'))
mkdirSync(join(cwd, 'b'))
const files = []
for (let i = 0; i < pairs; i++) {
writeFileSync(join(cwd, 'a', `f${i}`), `file ${i}\n`)
linkSync(join(cwd, 'a', `f${i}`), join(cwd, 'b', `f${i}`))
}
for (const d of ['a', 'b']) for (let i = 0; i < pairs; i++) files.push(`${d}/f${i}`)
const { version } = JSON.parse(
readFileSync(createRequire(import.meta.url).resolve('tar/package.json'), 'utf8'),
)
const file = join(cwd, 'out.tar')
let settled = 'never settled'
tar.create({ cwd, file }, files).then(
() => (settled = 'resolved'),
e => (settled = `rejected: ${e.message}`),
)
process.on('exit', code =>
console.log(
`tar ${version}, ${pairs} pairs: ${settled}, exit code ${code}, archive ${statSync(file).size} bytes`,
),
)
File size and links outside the archived set make no difference. On our real tree (~20k files) this branch stalled with the head a parked link ( My read of the mechanism (consistent with the fix below, but not traced line by line): the head step here runs once per Keeping the head step going while the head changes fixes all of the above, plus the #458 repro (40448 bytes) and our 20k-file tree (archive byte-identical to 7.5.13): --- a/src/pack.ts (as of #461)
+++ b/src/pack.ts
@@ [PROCESS]()
while (this[CURRENT]) {
const head = this[CURRENT]
this[PROCESSJOB](head)
- if (!head.ignore) {
- break
+ if (head.ignore) {
+ this[QUEUE].shift()
+ } else if (this[CURRENT] === head) {
+ // Piping the head can end it synchronously: [JOBDONE] then shifts
+ // the queue, and its own [PROCESS] call is dropped by PROCESSING.
+ break
}
- this[QUEUE].shift()
}It only loops again after the queue has shrunk, so it terminates. I haven't run the test suite with it, so take it as a pointer, not a vetted patch. |
Summary
Hardlinks in later subdirs can fill the async pack job table while the queue head is still pending.
PROCESSthen skipped the head becauseJOBSwas already at the limit, so nothing was piped,JOBSnever dropped, and the pack never ended.create({ file })hit the same stall: the promise never settled, the event loop drained, and Node exited 0 with a truncated archive.Always run
PROCESSJOBon the head even when the read-ahead table is full. Later jobs stay gated byjobs.Fixes #460
Test plan
Packstream andcreate({ file })both timed out at 8s)Linkentriesnpx tap test/pack.js test/create.ts(758 pass), including the existing hardlink timing tests