Zsh 히스토리 데이터 유실 버그 추적기 🐞
수년 동안 가끔은 분명 실행했다고 생각한 명령어가 Z shell 히스토리 파일(~/.zsh_history)에서 사라진 것을 발견하곤 했습니다. 이 글에서는 제가 어떻게 그 버그를 추적했는지 보여드리겠습니다. 스포일러를 먼저 말씀드리자면, 결국 Zsh에 패치를 넣어 일부러 요란하게 크래시를 내고 그 코어 덤프를 분석한 것이 결정적인 전략이었습니다!
좋은 소식부터
Zsh 5.9.2(2026년 7월 12일 릴리스)에는 이 문제를 수정한 패치가 포함되어 있습니다. 재미를 망치지 않으려면 이 추적기를 다 읽은 뒤에 열어 보세요.
스포일러: upstream 수정 링크
증상
가끔 전날 분명 실행한 명령어가 셸 히스토리에서 검색되지 않는 것을 발견했습니다. 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)에서도 마찬가지인데, 이 API 자체는 해당 정보를 제공합니다! 직접 확인해 보니 커널은 PID를 보내고 있었지만 fsnotifywait가 표시하지 않을 뿐이었습니다.
fatrace
다행히 프로세스 이름과 PID를 표시해 주는 fatrace(8)가 있습니다.
fatrace(8)로 본 Zsh의 히스토리 재작성 모습은 다음과 같습니다:
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이 전체 히스토리 파일을 쓰지 못하는 이유를 찾기 위해 코드를 살펴보기로 했습니다.
savehistfile의 제어 흐름은 따라가기가 꽤 어렵지만, 코드를 수정해 .zsh_history.new에 50000줄 미만으로 쓴 뒤 제 .zsh_history를 그 잘린 새 파일로 교체하기 전에 크래시를 내도록 만드는 것은 쉽습니다(zsh-5.9.1 기준):
--- 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이 애초에 더 짧은 히스토리를 남겼기 때문이라는 것을 깨달았습니다!
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를 고정(pinning)한다면 zsh와 zsh-common 패키지를 둘 다 고정하세요. 그렇지 않으면 언젠가 zsh 패키지 자체가 사라지는 상황을 겪을 수도 있습니다…
버그의 원인은 무엇이었을까?
종료 시 zexit은 히스토리를 압축하기 위해 savehistfile을 호출합니다. 세션 동안에는 히스토리 항목이 점진적으로 덧붙여지지만, 셸이 종료될 때는 히스토리 파일이 압축됩니다(예를 들어 설정된 경우 크기 제한을 적용하기 위해). 따라서 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이 가능한 파일이라면, 스트림이 해당 파일 디스크립션에 대한 활성 핸들인 경우, 기저 open 파일 디스크립션의 파일 오프셋은 스트림의 파일 위치로 설정되어야 한다.
Zsh는 스트림을 얻기 위해 fopen()을 사용하므로, glibc는 4096바이트 단위로 읽고 스트림을 닫을 때 기저 파일 디스크립터를 다시 seek해야 현재 4096바이트 청크에서 이미 읽은 부분을 다음 스트림이 올바르게 다시 읽을 수 있습니다. (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 버전, upstream 커밋, 메일링 리스트 스레드, 체인지로그나 릴리스 노트는 참고하지 마세요. 이 소스로부터 원인을 도출하는 것이 목적이며, 나중에 어떻게 수정되었는지를 찾아보는 것이 아닙니다.
답변 마지막에는 정확히
## 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를 수반하지 않는다. 하지만 트레이스에는HFILE_FAST동작인lseek(offset, SEEK_SET)가 보인다. 따라서SHAREHISTORY가 설정되어 있어야 한다”며 인터럽트를 배제했습니다 — 제거 논리를 유지하기 위해 zshrc의unsetopt SHARE_HISTORY를 무시한 것입니다.
eval에 더 많은 오케스트레이션을 도입해(한 서브 에이전트는 이론을 생성하고, 다른 에이전트는 이를 추적하며 반증/검증하는 식으로) 성공률이 올라간다는 것을 확인했습니다. 마찬가지로 프롬프트와 하네스를 달리하면 개별 모델의 성능을 훨씬 더 끌어올릴 수 있을 것으로 기대합니다.
가장 흔한 실패 양상은 모델이 잘못된 이론을 선택한 뒤 그 검증에만 매달려 다른 이론으로 돌아오지 않는 경우였습니다. 어쩌면 더 성능이 좋은 모델은 과학적 방법론을 더 잘 따르는, 더 나은 방법론을 가지고 있기 때문일지도 모릅니다?
글을 무작위로 읽기