Skip to content

Stop the shim silently going deaf mid-command - #3

Merged
edaywalid merged 3 commits into
edaywalid:mainfrom
lai0xn:fix/shim-stops-journaling
Jul 29, 2026
Merged

Stop the shim silently going deaf mid-command#3
edaywalid merged 3 commits into
edaywalid:mainfrom
lai0xn:fix/shim-stops-journaling

Conversation

@lai0xn

@lai0xn lai0xn commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Two bugs where the shim stops journaling with no sign to the user, so undo says "nothing to undo" for changes it should have captured.

rmdir() leaks the reentrancy guard. It sets in_shim = 1 on entry but only clears it on the success path, so any failed rmdir leaves it set for the rest of the process — every later unlink/open/rename sees armed() == false and goes unrecorded. rmdir a b with a non-empty a is enough to lose b. Every other interposed function clears it on all paths.

The mod dedup table never resets between sessions. It's process-global with no session key. Fine for a fresh child per command, wrong under UNDO_CAPTURE_SHELL=1 where the shim lives in the shell across every command: a file backed up once is skipped forever after, so later edits are unrecoverable. Now keyed on the session dir.

Minor: undo run returned 255 for signal deaths (ExitCode() is -1); now 128+signal.

e2e cases 19 and 20 both fail on the old shim. Case 20 uses python3 to drive the one-process-two-sessions path, since bash's export doesn't reliably update the C environ the shim reads; it skips where python3 is absent so the musl job doesn't break.

@edaywalid

Copy link
Copy Markdown
Owner

Verified each change independently:

  • Without the rmdir fix, case 19 fails with nothing to undo.
  • Without the dedup keying, case 20 fails with second session recorded nothing.
  • undo run -- sh -c 'kill -TERM $$' now returns 143 instead of 255.

Nice that the rmdir repro is just rmdir a b.

One thing before merging: dedup_reset now frees the table, but dedup_tab is global rather than __thread. That means one thread could free it while another is in mod_seen(), leading to a possible use-after-free. The window is probably small, but since the shim is loaded into every process, it seems worth avoiding. Would clearing the slots instead of freeing them work, or is there a reason this race can't happen?

Two smaller things:

  • Could the exit code fix be split into its own commit? It's unrelated to the shim changes and has no test.
  • Needs a rebase. test/e2e.sh now conflicts because main already uses cases 19 and 20.

lai0xn added 2 commits July 29, 2026 11:40
Two paths made the shim stop journaling without any sign to the user,
which is the worst failure mode this tool has: `undo` reports "nothing
to undo" for changes it should have captured.

rmdir() set the reentrancy guard on entry but only cleared it on the
success path, so any failed rmdir (a non-empty directory, ENOENT) left
in_shim set for the rest of the process. Everything that process did
afterwards saw armed() == false and went unrecorded. `rmdir a b` with a
non-empty `a` was enough to lose `b`.

The mod dedup table was keyed on nothing, so it persisted across
sessions in a process that outlives one command -- exactly the
UNDO_CAPTURE_SHELL=1 setup, where the shim lives in the shell itself.
A file written in one command suppressed its own backup in every later
one. Reset the table when the session directory changes.

The table now holds path hashes rather than strdup'd paths, so resetting
it is a memset with nothing to free: a preload shim that mallocs inside
an interposed open() is a hazard on its own, and freeing entries a
second thread may be reading would be worse than the leak that avoiding
the free implies.
exec.ExitError.ExitCode() is -1 when the child was killed by a signal,
and os.Exit(-1) becomes a bare 255, so `undo run -- <cmd>` was not the
transparent wrapper it looks like. Report what a shell would.
@lai0xn
lai0xn force-pushed the fix/shim-stops-journaling branch from b6c0a20 to e15ac7f Compare July 29, 2026 10:47
The table can now lose a backup rather than only add one, which the
comment did not say. The odds are negligible and the trade is right, but
it should be written down next to the code that makes it.

@edaywalid edaywalid left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified each change by reverting it alone and rerunning. Without the rmdir fix, case 20 fails with "nothing to undo". Without the dedup keying, case 21 fails with "second session recorded nothing". Case 22 covers the signal exit code.

The rework to hash slots is better than what I was going to suggest. It drops the use-after-free window when the session changes mid-process, and it takes strdup out of an interposed open(), so the shim now has no malloc, strdup or free anywhere. glibc floor is unchanged at 2.34.

I pushed one commit on top: a comment noting that hash slots mean the table can now lose a backup rather than only add one. The odds are around 1e-10 for a command touching 100k distinct paths and the trade is clearly right, it just wasn't written down.

Thanks for splitting the exit code change out and rebasing.

@edaywalid
edaywalid merged commit 68ad741 into edaywalid:main Jul 29, 2026
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.

2 participants