Skip to content

fix: keep packing the queue head when the job table is full - #461

Open
official-burak wants to merge 1 commit into
isaacs:mainfrom
official-burak:fix/pack-hardlink-job-deadlock
Open

official-burak wants to merge 1 commit into
isaacs:mainfrom
official-burak:fix/pack-hardlink-job-deadlock

Conversation

@official-burak

@official-burak official-burak commented Aug 30, 2026 •

Copy link
Copy Markdown

Summary

Hardlinks in later subdirs can fill the async pack job table while the queue head is still pending. PROCESS then skipped the head because JOBS was already at the limit, so nothing was piped, JOBS never 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 PROCESSJOB on the head even when the read-ahead table is full. Later jobs stay gated by jobs.

Fixes #460

Test plan

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
@llshicc

llshicc commented Oct 13, 2026

Copy link
Copy Markdown

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 jobs (4) entries are hardlinks to files packed earlier in the same archive.

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`,
  ),
)

create({file}), 5 runs each, identical on Node 24.5.0 and Bun 1.3.14 (macOS):

4 pairs 5 pairs 12 pairs
7.5.13 resolves, 7168 B resolves, 8704 B resolves, 19456 B
7.5.22 never settles, exit 0, 4096 B never settles, exit 0, 4096 B never settles, exit 0, 4096 B
7.5.22 + #461 resolves, 7168 B never settles, exit 0, 5632 B never settles, exit 0, ~13 KB
7.5.22 + #461 + diff below resolves, 7168 B resolves, 8704 B resolves, 19456 B

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 (pending=true pendingLink=true entry=false) and JOBS=47. Under Bun that build never exited: it spun at 100% CPU until CI cancelled it.

My read of the mechanism (consistent with the fix below, but not traced line by line): the head step here runs once per [PROCESS] call. When piping the head ends it synchronously (its data already buffered), [JOBDONE] shifts the queue and calls [PROCESS] re-entrantly, which returns at once because this[PROCESSING] is still set. The outer [PROCESS] has already done its head step, and its for loop is gated on this[JOBS] < this.jobs, so the new head is never processed.

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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Async Pack still deadlocks on hardlinked files in v7.5.22 (#458 not fixed); create({file}) turns it into a silent exit-0 no-op

2 participants