Zsh 히스토리 데이터 유실 버그를 추적하다 🐞
원문은 Michael Stapelberg님이 에 게재했습니다. 이 블로그 구독하기
수년 동안 가끔 분명히 실행했던 명령어가 Z shell 히스토리 파일(~/.zsh_history)에서 사라진 것을 발견하곤 했다. 이 글에서는 그 버그를 어떻게 추적했는지 보여주려 한다. 스포일러를 먼저 말하자면, 결국 Zsh에 패치를 넣어 요란하게 크래시되도록 만든 뒤 그 코어 덤프를 분석한 것이 결정적인 전략이었다!
좋은 소식부터
Zsh 5.9.2(2026년 7월 12일 릴리스)에는 이 문제에 대한 수정이 포함되어 있다 — 재미를 놓치지 않으려면 이 추적 과정을 다 읽은 후에 열어보기 바란다.
스포일러: 업스트림 수정 링크
증상
가끔 전날 분명히 실행했던 명령어가 셸 히스토리에서 전혀 검색되지 않는 것을 발견했다. Ctrl+R로 역방향 히스토리 검색을 해도 결과가 나오지 않는 식이었다. 그럴 때마다 셸 히스토리 파일에는 아주 오래된 항목만 남아 있었고, 수년 치의 최신 항목들이 통째로 사라져 있었다.
처음 몇 번은 일일 백업에서 셸 히스토리를 복원하고 더 깊이 파고들지 않았다. 하지만 문제는 계속 반복됐다.
.zsh_history에는 눈에 보이는 손상(출력 불가능한 문자나 잘린 텍스트 줄 등)이 없었고, 파일의 줄 수도 매번 일정하지 않다는 것을 알게 됐다.
당시에는 이 문제가 Zsh 자체 때문인지, 다른 프로그램 때문인지, 혹은 여러 zsh(1) 프로세스가 함께 작용해서 생긴 것인지 명확하지 않았다.
나의 Zsh 히스토리 설정
~/.zshrc에 다음과 같은 히스토리 관련 옵션을 설정해 두고 있다:
# Load 4000 lines of history (for Ctrl+R backward search), but save O(∞)
HISTSIZE=4000
HISTFILE=~/.zsh_history
SAVEHIST=10000000
# Do not save (adjacent) duplicate entries
setopt HIST_IGNORE_DUPS
# Append history entries to `~/.zsh_history` when commands are run.
setopt INC_APPEND_HISTORY
# …but do not share history (enabled by default in NixOS’s /etc/zshrc).
unsetopt SHARE_HISTORY
실제로 이는 각각의 셸이 별도의 세션으로 동작하면서 모든 명령어를 공유된 ~/.zsh_history에 스트리밍한다는 의미다. 히스토리는 의도적으로 공유하지 않으므로, 다른 셸이 쓴 항목에 접근하고 싶을 때는 명시적으로 exec zsh를 실행한다.
동작 추적하기
2024년 12월 Mastodon에 도움을 요청했을 때(이미 이 문제를 겪고 진단한 사람이 있기를 바라는 마음이었다), Zsh 히스토리 파일을 자르거나(혹은 변경하는?) 범인을 찾기 위해 inotify나 fsevents 같은 파일 시스템 변경 감지 메커니즘을 써보라는 제안을 받았다.
다음 절에서는 내가 시도해 본 Linux에서 사용 가능한 옵션들을 차례로 살펴본다.
inotify
inotify(7) Linux 커널 서브시스템은 Linux에서 가장 오래된 파일 시스템 변경 감지 API 중 하나다(2005년 릴리스). Zsh가 히스토리 파일을 어떻게 수정하는지 제대로 이해하려면 .zsh_history만 감시해서는 충분하지 않다:
midna ~ % inotifywait --monitor .zsh_history
Setting up watches.
Watches established.
.zsh_history OPEN
.zsh_history ACCESS
.zsh_history ACCESS
[…]
.zsh_history ACCESS
.zsh_history CLOSE_NOWRITE,CLOSE
.zsh_history ATTRIB
.zsh_history CLOSE_WRITE,CLOSE
.zsh_history DELETE_SELF
^C
파일이 열리고, 접근(= 읽기)된 뒤… 삭제된다고?!
상위 디렉터리를 감시하면 전체 그림이 보인다:
midna ~ % inotifywait --monitor ~
/home/michael/ OPEN .zsh_history
/home/michael/ ACCESS .zsh_history
/home/michael/ ACCESS .zsh_history
[…]
/home/michael/ ACCESS .zsh_history
/home/michael/ CLOSE_NOWRITE,CLOSE .zsh_history
/home/michael/ CLOSE_WRITE,CLOSE .zsh_history
/home/michael/ OPEN .zsh_history
/home/michael/ CLOSE_WRITE,CLOSE .zsh_history
/home/michael/ OPEN .zsh_history
/home/michael/ ACCESS .zsh_history
/home/michael/ CLOSE_NOWRITE,CLOSE .zsh_history
/home/michael/ CREATE .zsh_history.new
/home/michael/ OPEN .zsh_history.new
/home/michael/ ATTRIB .zsh_history.new
/home/michael/ MODIFY .zsh_history.new
/home/michael/ CLOSE_WRITE,CLOSE .zsh_history.new
/home/michael/ MOVED_FROM .zsh_history.new
/home/michael/ MOVED_TO .zsh_history
/home/michael/ CLOSE_WRITE,CLOSE .zsh_history
즉 Zsh는 기존 히스토리 파일 내용을 읽어 새 파일에 쓴 뒤, 새 파일을 기존 파일 이름으로 rename하여 기존 파일을 삭제하는 방식이었다. 이제야 이해가 된다!
안타깝게도 이 파일 시스템 이벤트를 일으킨 프로세스의 PID는 보이지 않는다. fanotify(7)을 사용하는 형제 유틸리티인 fsnotifywait(1)로도 마찬가지다. fanotify(7)는 원래 이 정보를 제공하는 API다! 확인해 보니 커널은 PID를 보내고 있는데, fsnotifywait가 이를 표시하지 않을 뿐이었다.
fatrace
다행히 프로세스 이름과 PID를 표시해 주는 fatrace(8)이 있다.
Zsh의 히스토리 재작성 과정이 fatrace(8)에서는 이렇게 보인다:
zsh(197994): CWO /home/michael/.zsh_history
zsh(197994): O /home/michael/.zsh_history
zsh(197994): R /home/michael/.zsh_history
zsh(197994): R /home/michael/.zsh_history
[…]
zsh(197994): R /home/michael/.zsh_history
zsh(197994): C /home/michael/.zsh_history
zsh(197994): + /home/michael
zsh(197994): O /home/michael/.zsh_history.new
zsh(197994): W /home/michael/.zsh_history.new
zsh(197994): W /home/michael/.zsh_history.new
zsh(197994): W /home/michael/.zsh_history.new
[…]
zsh(197994): W /home/michael/.zsh_history.new
zsh(197994): CW /home/michael/.zsh_history.new
zsh(197994): <> /home/michael
zsh(197994): CW (deleted)
zsh(197994): C /nix/store/80vwnjjgcrbp41pk927r8lzybjhy0k73-zsh-5.9.1/bin/zsh
[…]
이제 PID를 알 수 있으니 여러 프로세스가 셸 히스토리 손상에 관여했는지 확인할 수 있다. 하지만 각 Zsh PID가 얼마나 많은 데이터를 읽고 쓰는지 알 수 없으니, fatrace 로그만으로는 여전히 무슨 일이 일어났는지 명확하지 않다.
strace
물론 strace(1), 특히 -k 플래그를 써서 Zsh 동작을 더 자세히 들여다볼 수도 있다. 하지만 모든 (대화형) Zsh 프로세스마다 strace를 걸도록 준비하는 것은 물류적으로 악몽 같았고, 셸에 항상 strace를 걸면 미묘하게 동작이 달라질 수도 있을 것 같아 strace 경로는 추진하지 않았다.
(재현 케이스를 확보한 뒤에는 strace 사용이 훨씬 쉬워졌고 매우 유용했다.)
bpftrace
Zsh의 읽기와 쓰기 동작을 더 자세히 보기 위해 bpftrace(8)를 활용할 수 있다.
시작으로, 모든 open(2) 시스템 호출마다 실행되어 어떤 프로세스가 .zsh_history 파일을 열었는지 유저 스택 트레이스와 함께 기록하는 다음과 같은 bpftrace 프로그램을 만들었다:
tracepoint:syscalls:sys_enter_open,
tracepoint:syscalls:sys_enter_openat,
tracepoint:syscalls:sys_enter_openat2
/str(args.filename) == "/home/michael/.zsh_history" || str(args.filename) == ".zsh_history"/
{
printf("%-6d %-16s open(%s)%s", pid, comm, str(args.filename), ustack);
}
NixOS 26.05에서는 다음과 같이 프로그램을 실행할 수 있다:
midna ~ % nix shell nixpkgs#bpftrace
midna ~ 2 % sudo bpftrace path.bt
Attached 3 probes
212030 zsh open(/home/michael/.zsh_history)
__internal_syscall_cancel+142
__syscall_cancel+20
__libc_open64+87
lockhistfile+642
readhistfile+2213
zsh_main+1118
__libc_start_call_main+117
__libc_start_main_alias_2+136
_start+37
212030 zsh open(/home/michael/.zsh_history)
__internal_syscall_cancel+142
__syscall_cancel+20
__libc_open64+87
_IO_file_open+51
_IO_file_fopen@@GLIBC_2.2.5+303
__fopen_internal+134
readhistfile+2277
zsh_main+1118
__libc_start_call_main+117
__libc_start_main_alias_2+136
_start+37
212030 zsh open(/home/michael/.zsh_history)
__internal_syscall_cancel+142
__syscall_cancel+20
__libc_open64+87
lockhistfile+642
savehistfile+165
zexit+204
zsh_main+1522
__libc_start_call_main+117
__libc_start_main_alias_2+136
_start+37
212030 zsh open(/home/michael/.zsh_history)
__internal_syscall_cancel+142
__syscall_cancel+20
__libc_open64+87
savehistfile+752
zexit+204
zsh_main+1522
__libc_start_call_main+117
__libc_start_main_alias_2+136
_start+37
212030 zsh open(/home/michael/.zsh_history)
__internal_syscall_cancel+142
__syscall_cancel+20
__libc_open64+87
_IO_file_open+51
_IO_file_fopen@@GLIBC_2.2.5+303
__fopen_internal+134
readhistfile+2277
savehistfile+2498
zexit+204
zsh_main+1522
__libc_start_call_main+117
__libc_start_main_alias_2+136
_start+37
^C
초기 성공에 힘입어 더 많은 시스템 호출을 다루도록 프로그램을 다음과 같이 확장했다:
전체 zshhisttrace.bt bpftrace 코드
#!/usr/bin/bpftrace
#include <fcntl.h>
#include <limits.h>
tracepoint:syscalls:sys_enter_open /comm == "zsh"/ {
printf("%s(%d) open: %s flags %x mode %x\n", comm, pid, str(args->filename), args->flags, args->mode);
}
tracepoint:syscalls:sys_enter_openat {
if (!strcontains(str(args->filename), "zsh_history")) {
delete(@openfn[tid]);
return;
}
@openfn[tid] = 1;
printf("%s(%d) openat: ", comm, pid);
if (args->dfd < 0x7fffffff) { /* ought to be != AT_FDCWD, but that does not work !?!? */
printf("[at fd %d]", args->dfd);
}
printf("%s flags %x mode %x\n", str(args->filename), args->flags, args->mode);
}
tracepoint:syscalls:sys_exit_openat /@openfn[tid]/ {
@reads[tid,(int64)args->ret] = 1; // TODO: bpftrace 0.22 introduces has_key
@writes[tid,(int64)args->ret] = 1; // TODO: bpftrace 0.22 introduces has_key
}
tracepoint:syscalls:sys_enter_close /@reads[tid,(int64)args->fd]/ {
printf("%s(%d) close %d (reads: %d, writes: %d)\n", comm, pid, args->fd, @reads[tid,(int64)args->fd]-1, @writes[tid,(int64)args->fd]-1);
delete(@reads[tid,(int64)args->fd]);
delete(@writes[tid,(int64)args->fd]);
}
tracepoint:syscalls:sys_enter_rename /comm == "zsh"/ {
printf("%s(%d) rename:", comm, pid);
printf("%s -> %s\n", str(args->oldname), str(args->newname));
}
tracepoint:syscalls:sys_enter_symlink /comm == "zsh"/ {
printf("%s(%d) symlink ", comm, pid);
printf("%s -> %s\n", str(args->oldname), str(args->newname));
}
tracepoint:syscalls:sys_enter_unlink /comm == "zsh"/ {
printf("%s(%d) unlink ", comm, pid);
printf("%s\n", str(args->pathname));
}
tracepoint:syscalls:sys_enter_unlinkat /comm == "zsh"/ {
printf("%s(%d) unlinkat ", comm, pid);
printf("%s\n", str(args->pathname));
}
tracepoint:syscalls:sys_enter_lseek /comm == "zsh"/ {
printf("%s(%d) lseek fd %d offset %d whence %d\n", comm, pid, args->fd, args->offset, args->whence);
}
tracepoint:syscalls:sys_enter_read /@reads[tid,(int64)args->fd]/ {
@reads[tid,(int64)args->fd] += args->count;
}
tracepoint:syscalls:sys_exit_read /comm == "zsh"/ {
if (args->ret <= 0) {
printf("%s(%d) read = %d\n", comm, pid, args->ret);
}
}
tracepoint:syscalls:sys_exit_write /comm == "zsh"/ {
if (args->ret <= 0) {
printf("%s(%d) write = %d\n", comm, pid, args->ret);
}
}
tracepoint:syscalls:sys_enter_write /@writes[tid,(int64)args->fd]/ {
@writes[tid,(int64)args->fd] += args->count;
}
bpftrace를 더 깊이 파고들고 싶다면, 내가 유용하다고 느낀 자료 몇 가지를 소개한다:
- 업스트림 bpftrace 문서
- 블로그 글 “First steps in system-wide Linux tracing” by Martin Pitt (2020)
- LSFMM 발표 “BPF Observability” by Brendan Gregg (2019)
이 프로그램을 백그라운드에서 상시 실행하는 systemd 유닛을 만들었다(비용이 충분히 저렴해 보였다). 덕분에 다음과 같이 로그를 확인할 수 있다:
midna % journalctl -fu zshhisttrace
cp(2338700) close 3 (reads: 3407872, writes: 0)
zsh(231222) symlink /pid-231222/host-midna -> /home/michael/.zsh_history.LOCK
zsh(231222) openat: /home/michael/.zsh_history flags 541 mode 180
zsh(231222) close 3 (reads: 0, writes: 0)
zsh(231222) openat: /home/michael/.zsh_history flags 0 mode 0
zsh(231222) lseek fd 3 offset 0 whence 1
zsh(231222) read = 0
zsh(231222) close 3 (reads: 52895744, writes: 0)
zsh(231222) unlink /home/michael/.zsh_history.new
zsh(231222) openat: /home/michael/.zsh_history.new flags c1 mode 180
zsh(231222) close 3 (reads: 0, writes: 52888907)
zsh(231222) rename:/home/michael/.zsh_history.new -> /home/michael/.zsh_history
zsh(231222) unlink /home/michael/.zsh_history.LOCK
어느 날 셸 히스토리가 잘린 것을 발견하고 로그를 확인했다. 결과는 다음과 같았다. read = 0 줄이 없다는 점, 즉 Zsh가 EOF까지 읽지 않았다는 점에 주목하자:
zsh(231233) symlink /pid-231233/host-midna -> /home/michael/.zsh_history.LOCK
zsh(231233) openat: /home/michael/.zsh_history flags 541 mode 180
zsh(231233) close 3 (reads: 0, writes: 0)
zsh(231233) openat: /home/michael/.zsh_history flags 0 mode 0
zsh(231233) lseek fd 3 offset 0 whence 1
zsh(231233) lseek fd 3 offset 0 whence 1
zsh(231233) lseek fd 3 offset 11572944 whence 0
zsh(231233) close 3 (reads: 11575296, writes: 0)
zsh(231233) unlink /home/michael/.zsh_history.new
zsh(231233) openat: /home/michael/.zsh_history.new flags c1 mode 180
zsh(231233) close 3 (reads: 0, writes: 11572944)
zsh(231233) rename:/home/michael/.zsh_history.new -> /home/michael/.zsh_history
zsh(231233) unlink /home/michael/.zsh_history.LOCK
크래시시키기!
위의 bpftrace 출력으로 Zsh가 내 .zsh_history 파일을 잘못 재작성하고 있음을 알 수 있다: 평소보다 적은 줄을 읽고, 그 적은 내용을 그대로 .zsh_history.new에 쓰고 있다.
이 시점에서 나는 readhistfile이 전체 히스토리 파일을 읽지 못하는 이유나 savehistfile이 전체 히스토리를 쓰지 못하는 이유를 찾기 위해 코드를 살펴보기로 했다.
control flow of savehistfile은 따라가기 꽤 어렵지만, 코드를 수정해(zsh-5.9.1) 50000줄 미만의 .zsh_history.new를 쓴 뒤 내 .zsh_history를 그 잘린 새 파일로 교체하기 전에 크래시되도록 만드는 것은 쉽다:
--- i/Src/hist.c
+++ w/Src/hist.c
@@ -2994,6 +2994,7 @@ savehistfile(char *fn, int err, int writeflags)
if (out) {
char *history_ignore;
Patprog histpat = NULL;
+ int lines_written = 0;
pushheap();
@@ -3048,6 +3049,7 @@ savehistfile(char *fn, int err, int writeflags)
ret = fputc(' ', out);
if (ret < 0 || (ret = fputc('\n', out)) < 0)
break;
+ lines_written++;
}
if (ret >= 0 && start && writeflags & HFILE_USE_OPTIONS) {
struct stat sb;
@@ -3062,6 +3064,10 @@ savehistfile(char *fn, int err, int writeflags)
}
if (fclose(out) < 0 && ret >= 0)
ret = -1;
+ if (tmpfile && lines_written < 50000) {
+ char *crashptr = (char*)0x23;
+ *crashptr = 42;
+ }
if (ret >= 0) {
if (tmpfile) {
if (rename(tmpfile, unmeta(fn)) < 0) {
Linux에서 이런 크래시가 유용한 곳에 남도록 하는 가장 쉬운 방법은 systemd-coredump(8)을 설치하는 것이다. 그러면 systemd가 코어 덤프를 자동으로 수집한다. coredumpctl(1)로 목록을 확인하고 다룰 수 있다. 이 코어 덤프에는 셸 히스토리가 포함되어 있으니 서드파티 서비스에 업로드하지 말자. Fedora의 ABRT는 마이크로 리포트만 보내는 것으로 보인다(즉 전체 셸 히스토리는 포함되지 않는다), 그리고 Ubuntu의 Apport는 기본적으로 비활성화되어 있다지만, 한 번 더 확인하는 것이 좋다.
디버그 심볼을 활성화한 패치 버전의 Zsh를 설치하고, 문제가 실제로 발생한 코어 덤프를 얻을 때까지 추가 조사를 미뤄두었다. 아니나 다를까, 며칠 뒤 coredumpctl로 확인하니 크래시가 찍혀 있었다! 백트레이스는 다음과 같았다:
midna % coredumpctl debug
gdb $ bt full
#0 0x000056040d781e19 in savehistfile (fn=0x56040f7a76b0 "/home/michael/.zsh_history", err=1, writeflags=0) at hist.c:3086
crashptr = 0x23 <error: Cannot access memory at address 0x23>
history_ignore = 0x0
histpat = 0x0
lines_written = 45546
t = 0x5604102a1f59 ""
tmpfile = 0x5604100ec210 "/home/michael/.zsh_history.new"
start = 0x5604102a1f40 "make -j32"
out = 0x56040f939400
he = 0x0
xcurhist = 45546
extended_history = 0
ret = 10
#1 0x000056040d781f72 in savehistfile (fn=0x56040f7a76b0 "/home/michael/.zsh_history", err=1, writeflags=32771) at hist.c:3121
remember_histactive = 0
history_ignore = 0x0
histpat = 0x0
lines_written = 0
t = 0x0
tmpfile = 0x0
start = 0x0
out = 0x56040f939400
he = 0x0
xcurhist = 51183
extended_history = 0
ret = 0
#2 0x000056040d751197 in zexit (val=0, from_where=ZEXIT_NORMAL) at builtin.c:6055
writeflags = 32768
#3 0x000056040d7888e2 in zsh_main (argc=2, argv=0x7ffd370c1758) at init.c:1950
errexit = 0
t = 0x7ffd370c1768
runscript = 0x0
zsh_name = 0x7ffd370c26bd "zsh"
cmd = 0x0
t0 = 162
#4 0x000056040d735d89 in main (argc=2, argv=0x7ffd370c1758) at ./main.c:93
No locals.
다시 소스로 돌아가 보니, 아마도 savehistfile이 더 짧은 히스토리 파일을 쓰고 있는 이유는 readhistfile이 더 짧은 히스토리를 남겼기 때문일 가능성이 높았다!
control flow of readhistfile은 따라가기 더 쉽다. 함수를 읽어보면 일찍 반환될 가능성이 하나 있다: Zsh가 시그널을 받을 때 읽기 루프가 break;로 중단된다:
// …
if (errflag & ERRFLAG_INT) {
/* Can't assume fast read next time if interrupted. */
lasthist.interrupted = 1;
break;
}
// …
크래시 시점에서 errflag와 lasthist.interrupted에 뭐가 들어 있는지 확인해 보자:
gdb $ p errflag
$1 = 2
gdb $ p lasthist.interrupted
$2 = 1
빙고! 즉 어떤 시그널이 관여된 것이다.
이 글의 범위를 벗어나는 이유로, 나는 mosh 세션 안에서 오래 실행되는 SSH 세션을 시작하고, 그 위에서 다시 여러 세션을 멀티플렉싱해서 사용하고 있다. 하루 일과가 끝난 뒤 이 구성을 정리할 때는 멀티플렉싱된 세션들에서 Ctrl+D(EOF를 보내 세션을 종료), 오래 실행 중인 SSH에서 Ctrl+C, 그리고 mosh 세션을 종료하기 위해 다시 Ctrl+D를 누른다.
(mosh 세션을 깔끔하게 종료하지 않으면 서버에 그대로 남아 이후 로그인할 때 고아 세션에 대해 알려준다. 나는 그런 고아 세션이 쌓이는 것을 피하고 싶었다.)
그래서 실제로는 모든 창이 사라질 때까지 Ctrl+D, Ctrl+C, Ctrl+D, Ctrl+C를 반복해서 누른다. 그 과정에서 아마도 Zsh 세션을 종료(Ctrl+D)한 뒤, 히스토리 재작성에 시간이 충분히 걸리면 그 readhistfile을 인터럽트(Ctrl+C)하고 있었던 것이다.
이 단서들을 바탕으로 독립적인 재현 케이스를 만들어 2025년 3월 zsh-workers 메일링 리스트에 버그 리포트를 보냈다. Bart Schaefer가 이를 살펴보고 2025년 4월에 수정안을 게시했다(감사합니다!).
한동안 Zsh 릴리스가 없어 수정안이 실제로 릴리스되기까지는 오랜 시간이 걸렸다. 그리고 5.9.1 릴리스가 이루어졌을 때, Bart의 수정이 릴리스 담당자에 의해 누락된 것으로 드러났다! 내가 이 누락을 지적했고, 다행히 Zsh 5.9.2에는 수정이 포함되었다.
나는 Bart의 패치를 적용한 Zsh 5.9를 실행해 왔고, 5.9.2가 내 컴퓨터에 도착할 때까지 그 버전을 고정해 둘 예정이다. Debian에서 zsh를 고정한다면 zsh와 zsh-common 패키지 둘 다 고정하자. 그렇지 않으면 어느 날 zsh 패키지가 아예 없어지는 상황을 맞을 수도 있다…
버그의 정체는 무엇이었나?
종료 시 zexit은 히스토리를 압축하기 위해 savehistfile을 호출한다: 세션 동안에는 히스토리 항목이 점진적으로 append되지만, 셸 종료 시에는 히스토리 파일이 압축된다(예를 들어 설정된 경우 크기 제한을 적용하기 위해). 그래서 savehistfile은 전체 히스토리를 읽어(readhistfile) 다시 쓴다.
readhistfile은 시그널이 발생하면 인터럽트될 수 있다(errflag & ERRFLAG_INT를 검사해 읽기 루프를 단락시킨다). 하지만 savehistfile은 종료 시 셸 히스토리를 쓸 때 인터럽트 여부를 확인하지 않았다. 따라서 savehistfile은 (불완전한) 히스토리를 그대로 써버렸고, 실제 히스토리가 잘리게 된 것이다.
앞서 수집한 bpftrace 출력을 해독해 보자:
zsh(231233) openat: /home/michael/.zsh_history flags 0 mode 0
zsh(231233) lseek fd 3 offset 0 whence 1
# […] reads are aggregated, see below […]
# […] interrupt happens here […]
# lseek(3, 0, SEEK_CUR) = query the current seek offset
zsh(231233) lseek fd 3 offset 0 whence 1
# SEEK_SET at fclose(), as POSIX mandates (see below)
zsh(231233) lseek fd 3 offset 11572944 whence 0
zsh(231233) close 3 (reads: 11575296, writes: 0)
zsh(231233) unlink /home/michael/.zsh_history.new
zsh(231233) openat: /home/michael/.zsh_history.new flags c1 mode 180
zsh(231233) close 3 (reads: 0, writes: 11572944)
zsh(231233) rename:/home/michael/.zsh_history.new -> /home/michael/.zsh_history
왜 lseek일까? POSIX.1-2017의 fclose()에 관한 설명에 따르면
파일이 아직 EOF에 있지 않고, 해당 파일이 탐색(seek)이 가능한 경우, 스트림이 해당 파일 기술자에 대한 활성 핸들이라면, 내부 열린 파일 기술자의 파일 오프셋은 스트림의 파일 위치로 설정되어야 한다.
Zsh는 스트림을 얻기 위해 fopen()을 사용하므로, glibc는 4096바이트 단위로 읽고 스트림을 닫을 때 현재 4096바이트 청크에서 이미 읽은 부분을 다음 스트림이 올바르게 다시 읽을 수 있도록 내부 파일 디스크립터를 뒤로 탐색(seek)해야 한다. (Zsh는 파일을 즉시 닫으므로 이 seek은 무의미하지만, glibc는 이를 알 수 없다.)
결론
데이터 손실을 일으키는 이런 버그가 인기 있는 셸에서 10년 동안 수정되지 않고 남아 있었다는 것은 놀라운 일이다(알고 있었는가? Apple은 2019년에 macOS의 기본 로그인 셸을 Zsh로 바꿨다).
물론 대부분의 사용자는 SIGINT가 전송될 가능성이 높은 방식으로 셸 세션을 종료하는 나의 습관을 공유하지 않겠지만, 일부 사용자는 분명 히스토리의 일부를 잃었을 것이라고 생각된다.
이제 이 문제가 수정되어 매우 기쁘다! 히스토리 파일이 잘리는 현상을 겪고 있는데 이 글에서 설명한 문제가 아니라면, 혹시 실수로 HISTFILE을 export한 것은 아닌지 확인해 보자. 몇 년 전에 내가 겪었던 HISTFILE 관련 추가 함정에 대해서는 부록 A를 참고하자.
이 글을 쓰면서 떠오른 또 다른 당연한 질문이 있다: 나는 LLM이 코딩과 문제 해결에 인상적으로 뛰어나지기 전에 이 문제를 추적했다. 오늘날의 AI 코딩 에이전트라면 이 버그를 찾을 수 있을까? 자세한 내용은 부록 B를 참고하면 되지만, 답은 이렇다: 네, 오늘날의 프론티어 모델은 이 버그를 찾을 수 있다!
부록 A: 보너스 함정: export된 HISTFILE
Emacs의 TRAMP 모드를 사용하면 기본적으로 HISTFILE을 export한다. 예를 들어 emacs /ssh:keep:/srv/keep을 실행한 뒤 M-x shell을 사용하면 환경에서 HISTFILE을 볼 수 있다:
/ssh:keep:/srv/keep/ #$ env | grep HISTFILE
HISTFILE=/home/michael/.tramp_history
/ssh:keep:/srv/keep/ #$
이는 함정이 된다. 대부분의 셸 설정은 HISTFILE을 unexport하지 않고 값만 바꾸기 때문이다. 예를 들어 내 ~/.zshrc에서는 HISTFILE=~/.zsh_history로 설정한다.
대화형 셸을 실행하면(zsh를 입력하고 Enter를 누르면) 결국 환경에 HISTFILE이 남게 된다:
/ssh:keep:/srv/keep/ #$ zsh
locale: Cannot set LC_CTYPE to default locale: No such file or directory
$ env | grep HISTFILE
HISTFILE=/home/michael/.zsh_history
$
…하지만 ssh(1)로 로그인할 때는 그렇지 않다:
midna ~ % ssh keep
Last login: Sat Aug 1 17:38:37 2026 from 100.64.1.1
keep ~ % env | grep HISTFILE
keep ~ %
셸에 특화된 HISTFILE을 export하는 것은 다른 셸이 다른 (기본) 설정으로 구성된 머신에서는 함정이 된다. 내 업무용 컴퓨터에서는 Linux 설치 시 bash에 기본적으로 HISTSIZE=64000과 HISTFILESIZE=64000을 설정하는데, 한 번은 내 ~/.zsh_history 파일이 실수로 64000줄로 잘린 적이 있다. 아마도 M-x shell을 실행하고, 이어서 zsh(내 설정을 불러오기 위해), 그리고 잠시 bash(설정을 source하고 스크립트를 실행하기 위해)를 실행하면서 생긴 일로 추정한다.
앞으로 이런 문제를 방지하기 위해 나는 내 ~/.zshrc에서 HISTFILE을 적극적으로 unexport하기로 했다.
부록 B: 보너스 질문: AI가 이 버그를 찾을 수 있을까?
한동안 직접 eval을 만들어 보는 것이 유용하겠다고 생각해 왔다. “eval”이라는 용어가 낯설다면 Anthropic의 “Demystifying evals for AI agents”를 참고하자.
처음에는 Simon Willison의 smevals로 시작했지만, 너무 미니멀하다는 것을 알게 됐다: 별도의 조치를 취하지 않으면 에이전트가 금방 eval 과제를 벗어나 정답을 엿보거나, 인터넷을 이용해 Zsh git 버전에서 이 버그가 이미 수정되었다는 것을 알아내 버렸다.
결국 UK AI Security Institute와 Meridian Labs가 만든 오픈소스 eval 프레임워크인 Inspect를 사용하게 됐고, 웹 UI는 매우 미니멀하지만 더 잘 동작했다.
이 eval은 금방 매우 비싸졌다! 이 eval을 약 3번 시도하는 데 토큰 비용으로 300달러가 훨씬 넘게 들었다. 아래 결과는 가장 최근 시도에서 나온 것이다. 모델이 올바른 사건의 순서를 설명할 때 합격점을 준다: 인터럽트가 errflag를 설정하고, 이로 인해 readhistfile이 중단되어 히스토리 파일이 잘리는 경우다.
Eval 구성: 증상 + bpftrace
전체 프롬프트, 정상/잘린 bpftrace 포함
로그아웃할 때 가끔 다음 날 돌아오면 .zsh_history 파일이 알 수 없이 잘려 있습니다. 왜 그럴까요?
Linux에서 zsh 5.9.1을 사용 중입니다. 이 파일은 zsh만 씁니다. 히스토리 파일에 대해 zsh가 수행하는 모든 syscall을 기록하는 bpftrace 프로그램이 있습니다.
정상적인 로그아웃은 이렇게 보입니다:
zsh(231222) symlink /pid-231222/host-midna -> /home/michael/.zsh_history.LOCK zsh(231222) openat: /home/michael/.zsh_history flags 541 mode 180 zsh(231222) close 3 (reads: 0, writes: 0) zsh(231222) openat: /home/michael/.zsh_history flags 0 mode 0 zsh(231222) lseek fd 3 offset 0 whence 1 zsh(231222) read = 0 zsh(231222) close 3 (reads: 52895744, writes: 0) zsh(231222) unlink /home/michael/.zsh_history.new zsh(231222) openat: /home/michael/.zsh_history.new flags c1 mode 180 zsh(231222) close 3 (reads: 0, writes: 52888907) zsh(231222) rename:/home/michael/.zsh_history.new -> /home/michael/.zsh_history zsh(231222) unlink /home/michael/.zsh_history.LOCK파일이 잘린 로그아웃은 이렇게 보입니다:
zsh(231233) symlink /pid-231233/host-midna -> /home/michael/.zsh_history.LOCK zsh(231233) openat: /home/michael/.zsh_history flags 541 mode 180 zsh(231233) close 3 (reads: 0, writes: 0) zsh(231233) openat: /home/michael/.zsh_history flags 0 mode 0 zsh(231233) lseek fd 3 offset 0 whence 1 zsh(231233) lseek fd 3 offset 0 whence 1 zsh(231233) lseek fd 3 offset 11572944 whence 0 zsh(231233) close 3 (reads: 11575296, writes: 0) zsh(231233) unlink /home/michael/.zsh_history.new zsh(231233) openat: /home/michael/.zsh_history.new flags c1 mode 180 zsh(231233) close 3 (reads: 0, writes: 11572944) zsh(231233) rename:/home/michael/.zsh_history.new -> /home/michael/.zsh_history zsh(231233) unlink /home/michael/.zsh_history.LOCK내 zshrc는 ./zshrc에 있습니다 — 문제가 발생한 머신에서 실제로 적용된 설정 그대로이므로, 어떤 옵션이 활성화되어(혹은 비활성화되어) 있는지 확인할 수 있습니다.
전체 zsh 5.9.1 소스 트리는 ./zsh-5.9.1에 있습니다 — 제가 실행 중인 버전과 정확히 동일합니다. 필요한 만큼 깊이 파고들어 보세요.
무슨 일이 일어나고 있는 것이며, zsh 소스의 무엇이 이를 일으키는 것일까요?
제공된 zsh 5.9.1 소스와 위의 증거만을 바탕으로 작업하세요. 더 새로운 zsh 버전, 업스트림 커밋, 메일링 리스트 스레드, 체인지로그나 릴리스 노트를 참고하지 마세요 — 요점은 나중에 어떻게 수정되었는지를 찾아보는 것이 아니라, 이 소스로부터 원인을 도출하는 것입니다.
답변 마지막에는 정확히
## Diagnosis라는 제목의 섹션을 두고, 최종 답변인 근본 원인과 원인 코드를 적어 주세요.
| 점수 | 모델 | 토큰 | 소요 시간 |
|---|---|---|---|
| ✅ 3 of 3 | openai/gpt-5.6-sol | 497,040 | 2m 29s |
| ✅ 3 of 3 | anthropic/claude-opus-5 | 5,440,676 | 26m 43s |
| ⚠️ 2 of 3 | openai/gpt-5.5 | 659,050 | 2m 43s |
| ⚠️ 1 of 3 | openai/gpt-5.6-terra | 673,336 | 1m 57s |
| ⚠️ 1 of 3 | google/gemini-3.1-pro-preview | 3,733,226 | 9m 31s |
| ⚠️ 1 of 3 | anthropic/claude-sonnet-5 | 9,323,989 | 29m 18s |
| ⚠️ 1 of 3 | google/gemini-3.5-flash | 6,746,305 | 12m 38s |
| ⚠️ 1 of 3 | moonshotai/kimi-k3 (open weight!) @ medium | 2,455,874 | 45m 6s |
| ⚠️ 1 of 3 | moonshotai/kimi-k3 (open weight!) @ high | 13,060,937 | 52m 2s |
| ⚠️ 1 of 3 | google/gemini-3-flash-preview | 19,370,711 | 30m 14s |
| ❌ | openai/gpt-5.1 | 118,948 | 1m 12s |
| ❌ | openai/gpt-5.4 | 286,288 | 1m 10s |
| ❌ | openai/gpt-5.6-luna | 549,724 | 1m 14s |
| ❌ | qwen/qwen3-coder | 623,110 | 5m 2s |
| ❌ | openai/gpt-5.2 | 1,306,586 | 1m 49s |
| ❌ | openai/gpt-5 | 2,858,548 | 7m 14s |
| ❌ | anthropic/claude-opus-4-8 | 3,402,954 | 9m 5s |
| ❌ | deepseek/deepseek-v4-flash-0731 (open weight!) | 5,570,798 | 25m 6s |
| ❌ | google/gemini-3.1-flash-lite | 6,945,359 | 2m 7s |
| ❌ | qwen/qwen3.8-max (open weight!) | 6,307,959 | 39m 32s |
| ❌ | deepseek/deepseek-v4-pro (open weight!) | 8,577,105 | 30m 7s |
| ❌ | anthropic/claude-haiku-4-5 | 10,646,235 | 7m 24s |
| ❌ | qwen/qwen3.6-max-preview | 19,689,360 | 27m 12s |
| ❌ | minimax/minimax-m3 (open weight!) | 19,937,971 | 46m 28s |
| ❌ | z-ai/glm-5.2 (open weight!) | 21,507,452 | 29m 30s |
Eval 변형: 습관 힌트 포함
이번 반복에서는 Ctrl+C와 Ctrl+D를 반복해서 누르는 습관에 대한 힌트를 포함했다. 이는 시그널과 인터럽트 처리를 암시하는 단서다:
참고로, 내 로그아웃 습관은 모든 터미널 창이 사라질 때까지 ctrl+c / ctrl+d를 반복해서 누른 뒤 남은 것을 확인하는 것입니다.
이는 모델이 문제를 얼마나 쉽게 이해하는지(혹은 이해하는지조차)를 측정한다.
| 점수 | 모델 | 토큰 | 소요 시간 |
|---|---|---|---|
| ✅ 3 of 3 | openai/gpt-5.6-sol | 393,895 | 1m 43s |
| ✅ 3 of 3 | openai/gpt-5.5 | 622,081 | 1m 31s |
| ✅ 3 of 3 | anthropic/claude-opus-5 | 1,583,601 | 7m 29s |
| ✅ 3 of 3 | anthropic/claude-opus-4-8 | 2,338,905 | 6m 4s |
| ✅ 3 of 3 | anthropic/claude-sonnet-5 | 3,132,322 | 14m 9s |
| ✅ 3 of 3 | moonshotai/kimi-k3 @ medium (open weight!) | 4,642,764 | 32m 25s |
| ✅ 3 of 3 | moonshotai/kimi-k3 @ high (open weight!) | 9,631,629 | 32m 17s |
| ✅ 3 of 3 | z-ai/glm-5.2 (open weight!) | 23,654,094 | 22m 37s |
| ⚠️ 2 of 3 | openai/gpt-5 | 2,345,526 | 3m 38s |
| ⚠️ 2 of 3 | google/gemini-3-flash-preview | 6,421,624 | 16m 41s |
| ⚠️ 2 of 3 | google/gemini-3.5-flash | 3,571,679 | 9m 7s |
| ⚠️ 2 of 3 | qwen/qwen3.8-max (open weight!) | 4,289,195 | 41m 49s |
| ⚠️ 1 of 3 | openai/gpt-5.6-luna | 426,974 | 1m 26s |
| ⚠️ 1 of 3 | openai/gpt-5.6-terra | 728,604 | 1m 31s |
| ⚠️ 1 of 3 | google/gemini-3.1-pro-preview | 3,525,585 | 6m 23s |
| ⚠️ 1 of 3 | deepseek/deepseek-v4-flash-0731 (open weight!) | 3,628,997 | 25m 37s |
| ⚠️ 1 of 3 | deepseek/deepseek-v4-pro (open weight!) | 7,751,190 | 30m 4s |
| ❌ | openai/gpt-5.4 | 266,719 | 45s |
| ❌ | openai/gpt-5.1 | 287,721 | 1m 9s |
| ❌ | openai/gpt-5.2 | 1,097,940 | 1m 30s |
| ❌ | qwen/qwen3-coder (open weight!) | 1,160,681 | 8m 31s |
| ❌ | anthropic/claude-haiku-4-5 | 5,663,995 | 5m 47s |
| ❌ | google/gemini-3.1-flash-lite | 5,839,696 | 1m 39s |
| ❌ | minimax/minimax-m3 (open weight!) | 10,733,126 | 27m 25s |
| ❌ | qwen/qwen3.6-max-preview | 13,322,764 | 30m 4s |
AI 결론
Claude Opus 5나 GPT 5.6 Sol 같은 최신 프론티어 모델은 증상 설명과 정상/실패 bpftrace만으로도 안정적으로 버그를 찾아낸다. 몇 번 시도해 보면 Gemini 모델로도 도달할 수 있다. 오픈 웨이트 모델 중에서는 힌트 없이 이 버그를 찾을 수 있는 것은 Kimi K3뿐이다.
프롬프트에 Ctrl+C + Ctrl+D 습관이 포함되면 더 많은 프론티어 모델이 안정적으로 문제를 찾아낸다(Claude Sonnet 5 포함!). 오픈 웨이트 모델 중에서는 GLM 5.2와 Kimi K3가 안정적으로 문제를 파악한 최초의 모델이다! 몇 번 시도하면 Gemini나 DeepSeek 모델로도 도달할 수 있다. Qwen이나 Minimax 모델은 통과시키지 못했다.
이는 특히 어떤 오픈 웨이트 모델이 실제로 Opus나 GPT만큼 잘 동작하는지를 추적하는 데(적어도 이 특정한 관점에서는) 정말 좋은 eval로 보인다. 현재로서는 Kimi K3가 가장 유능한 오픈 웨이트 모델로 보이지만, 그래도 이 문제를 안정적으로 진단하지는 못한다. GLM 5.2는 훨씬 작지만 — 힌트가 있으면 — 적어도 문제를 이해할 수 있다.
흥미롭게도 Qwen과 Minimax 모델을 포함한 거의 모든 모델이 올바른 가설을 고려했다는 점이다. Gemini 3.1 Flash Lite만이 올바른 가설을 전혀 제시하지 않았는데, 아마도 (비교적) 작은 모델이기 때문일 것이다.
그렇다면 모델들은 어디서 잘못됐을까? 이론을 검증/반증하는 단계에서다! 예를 들어 GLM 5.2는 bpftrace 출력의 lseek이 반드시 SHAREHISTORY가 설정되어 있음을 의미한다고 가정한다(하지만 그렇지 않다!):
glm-5.2는 짧은 읽기의 원인을 정확히 세 가지 — 손상,
HFILE_FAST탐색,errflag & ERRFLAG_INT— 로 꼽은 뒤, “옵션 1과 3은 0이 아닌 오프셋으로의 lseek을 수반하지 않는다. 하지만 트레이스는lseek(offset, SEEK_SET)을 보여주는데, 이는HFILE_FAST동작이다. 따라서SHAREHISTORY가 설정되어 있어야 한다”는 이유로 인터럽트를 배제했다 — 당신의zshrc에 있는unsetopt SHARE_HISTORY를 무시하고 배제를 유지한 것이다.
eval에 더 많은 오케스트레이션(한 서브에이전트가 이론을 만들고, 다른 에이전트가 이를 추적하며 반증/검증하는 등)을 적용하면 성공률이 올라간다는 것을 확인했다. 마찬가지로 프롬프트와 하네스를 달리하면 개별 모델의 성능을 훨씬 더 높일 수 있을 것으로 기대한다.
가장 흔한 실패 패턴은 모델이 잘못된 이론을 선택하고 그 검증에만 매몰되어 다른 이론으로 돌아오지 않는 것으로 보인다. 아마도 더 성능이 좋은 모델들은 과학적 방법론을 더 잘 따르는, 더 나은 방법론을 가지고 있는 것이 아닐까?
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기