tailscale/tailscale · error

reading %d (%x): %w

Error message

reading %d (%x): %w

What it means

Thrown by FS.PurgeAUMs while marking AUMs for deletion. Before setting the PurgedUnix flag, the chonk re-reads the stored record for each hash (a CBOR file named by hash under a 2-hex-char prefix directory). The error means the record for the i-th hash could not be read back. Note that os.ErrNotExist is NOT tolerated on this path: purging a hash that was never committed to this chonk, or whose file was deleted out-of-band, fails with this exact message.

Source

Thrown at tka/tailchonk.go:691

	return nil
}

// PurgeAUMs marks the specified AUMs for deletion from storage.
func (c *FS) PurgeAUMs(hashes []AUMHash) error {
	if len(hashes) == 0 {
		return nil
	}

	c.mu.Lock()
	defer c.mu.Unlock()

	c.invalidateIndexLocked()
	now := time.Now()
	for i, h := range hashes {
		stored, err := c.get(h)
		if err != nil {
			return fmt.Errorf("reading %d (%x): %w", i, h, err)
		}
		if stored.AUM == nil || stored.PurgedUnix > 0 {
			continue
		}

		err = c.commit(h, func(info *fsHashInfo) {
			info.PurgedUnix = now.Unix()
		})
		if err != nil {
			return fmt.Errorf("committing purge[%d] (%x): %w", i, h, err)
		}
	}
	return nil
}

// commit calls the provided updater function to record changes relevant
// to the given hash. The caller is expected to update the AUM and
// Children fields, as relevant.

View on GitHub (pinned to 6e0912f979)

Solutions

  1. If the wrapped error is os.ErrNotExist the purge goal is already met: drop that hash from the list and call PurgeAUMs again
  2. Ensure no second process is writing or deleting files in the chonk directory; stop the duplicate
  3. If the file exists but will not decode, move it aside (filename = the hex hash in the message) and drop the hash from the list
  4. Always recompute the delete list from this chonk's own AllAUMs(), never from a foreign node's state

Example fix

// before
if err := chonk.PurgeAUMs(toDelete); err != nil {
	return err // reading 3 (3f9a..): file does not exist
}

// after: only purge hashes that actually exist here
keep := toDelete[:0]
for _, h := range toDelete {
	if _, err := chonk.AUM(h); err == nil {
		keep = append(keep, h)
	}
}
return chonk.PurgeAUMs(keep)
Defensive patterns

Strategy: validation

Validate before calling

known, err := chonk.AllAUMs()
if err != nil { return err }
have := make(map[tka.AUMHash]bool, len(known))
for _, h := range known { have[h] = true }
purge := input[:0]
for _, h := range input {
	if have[h] { purge = append(purge, h) }
}

Type guard

func isStored(chonk *tka.FS, h tka.AUMHash) bool {
	_, err := chonk.AUM(h)
	return err == nil
}

Try / catch

if err := chonk.PurgeAUMs(hashes); err != nil {
	if errors.Is(err, os.ErrNotExist) {
		// target already removed; purge goal already met — safe to continue
		return nil
	}
	return err
}

Prevention

When it happens

Trigger: Calling PurgeAUMs with a delete list derived from another node's storage containing hashes this chonk never stored; the AUM file was removed by a second process or by hand between listing and purge; the stored file is corrupt so c.get()'s CBOR decode fails; permission changes prevent os.Open.

Common situations: tka.Compact computing a toDelete list while another process mutates the same state dir; state dir partially restored from backup; scripts pruning 'old' files in the tka directory by mtime.

Understand the failure class

Background: "failed to read file", EACCES, ENOENT and "could not read <path>" errors: when a program can't read a file from disk — this error's family across 49 libraries.

Related errors


AI-assisted analysis of tailscale/tailscale@6e0912f979 (2026-08-18). Data as JSON: /api/errors/d3d9e97f163c62c7. Report an issue: GitHub.