Defect Analysis: The Preference-File Lock — Blocked Exit, and a Void Lock in `'exists` Mode
Los mantenedores suelen responder en 3 días
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 4/5
- Tiempo estimado
- 3-5 días
- Aptitud para principiantes
- 45/100
- Tipo de issue
- Error
- Claridad
- Bastante claro
- Estado de actividad
- Activo
- Área
- operating-systems
Línea de trabajo
Empieza por racket/collects/racket/file.rkt, especialmente por call-with-file-lock y call-with-preference-file-lock; después, sigue las rutas de salida y de historial en readline/pread.rkt. Ejecuta el escenario aislado exit-repro.rktl y revisa los casos de verificación indicados. Se considera terminado cuando se reproduce el comportamiento aprobado por el mantenedor sin bloquear la salida ni realizar la escritura protegida después de que falle la adquisición del bloqueo.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
Defect Analysis: The Preference-File Lock — Blocked Exit, and a Void Lock in 'exists Mode
Status: diagnosed and verified; defect A fixed (installed copy), defect B patch pending a decision
Date: 2026-08-24
Environment: macOS 15 (Darwin 25.4.0), Racket v9.3 in-tree build (CS)
Components: racket/collects/racket/file.rkt (core, tracked by this repo)
readline/pread.rkt from readline-lib (separate upstream repo, installed copy only)
0. Conclusion in One Sentence
A zero-byte leftover lock file can make Racket impossible to quit; and fixing that
exposed a second, deeper defect: on every platform except Windows, call-with-file-lock
performs the guarded action anyway after failing to acquire the lock, so the
preference-file lock is void whenever a caller passes a failure callback that returns
normally.
The two defects are independent, but defect B is only reachable while fixing defect A.
1. Symptoms
While working normally at the REPL, the user sees an error unrelated to the operation at hand:
put-preferences: some other process has the preference-file lock, as indicated by
the existence of the lock file:
#<path:/Users/<user>/Library/Preferences/.LOCKorg.racket-lang.prefs.rktd>
[,bt for context]
And then discovers that neither (exit) nor Ctrl-D works any more — each attempt prints
the same error and returns to the prompt. The process can only be ended with kill.
In batch mode the symptom is milder: the error is printed (with a context trace), but the
process does eventually exit, with status 0.
2. Preconditions
All four must hold simultaneously:
| # | Condition | Why it matters | How to confirm |
|---|---|---|---|
| 1 | Platform is not Windows | preferences-lock-file-mode returns 'exists: the lock is the file's existence, with no OS-level flock |
(preferences-lock-file-mode) |
| 2 | A leftover lock file | A process was killed (SIGKILL/SIGTERM) mid-write, so the dynamic-wind release never ran |
see evidence below |
| 3 | readline is loaded | It installs a hook that writes preferences on exit | (require readline/pread); xrepl's ,input readline; racket -il readline |
| 4 | An exit is attempted | (exit), Ctrl-D, or a script running to completion |
— |
Evidence for condition 2 (from the affected machine):
.LOCKorg.racket-lang.prefs.rktd birth = mtime = 2026-08-21 14:51:10 size = 0
org.racket-lang.prefs.rktd birth = mtime = 2026-08-21 14:51:10 size = 3866
Both were created in the same second. So that preference write succeeded, but the
unlock immediately after it did not run — the process was killed inside the microsecond-wide
window between "written" and "unlocked". Three days later, with a critical section only as
long as a single file write, no process can plausibly still hold it legitimately.
3. Defect A: A Cleanup Exit Hook Without Exception Isolation Blocks the Exit
3.1 Unexpected behavior
(exit)/ Ctrl-D at the REPL become completely ineffective; the process cannot quit.- The error names the preference file and has nothing to do with what the user was doing,
making it very hard to trace. - It is fully deterministic, and persists for days or weeks (see §3.4).
3.2 Relevant code
readline/pread.rkt (unpatched original; installed copy at commit df55a811d5):
;; pread.rkt:65-66
(define (save-history)
(put-preferences '(readline-input-history) (list local-history)))
;; pread.rkt:107-109
(exit-handler
(let ([old (exit-handler)])
(lambda (v) (save-history) (old v))))
racket/collects/racket/file.rkt:
;; file.rkt:693-696 — the platform decides the lock semantics
(define (preferences-lock-file-mode)
(case (system-type)
[(windows) 'file-lock]
[else 'exists]))
;; file.rkt:731-750 — default behavior when the lock cannot be taken: raise
(define (call-with-preference-file-lock who kind get-lock-file thunk lock-there)
(define lock-style (preferences-lock-file-mode))
(define lock-file (get-lock-file))
(define failure-thunk
(if lock-there
(lambda () (lock-there lock-file))
(lambda ()
(case lock-style
[(file-lock) (error who "...")]
[else (error who
"~a, ~a: ~e"
"some other process has the preference-file lock"
"as indicated by the existence of the lock file"
lock-file)]))))
(call-with-file-lock kind lock-file thunk failure-thunk #:lock-style lock-style))
3.3 Trigger mechanism
The call chain for exit:
(exit) ──► (exit-handler) ; the wrapper installed at pread.rkt:109
└─► (save-history) ; pread.rkt:65
└─► put-preferences ; file.rkt:919
└─► call-with-preference-file-lock ; file.rkt:944
└─► lock file exists → failure-thunk → error ; file.rkt:744-748
│
╳ ───────────────────────────┘
(old v) is never reached — the actual exit never happens
The crux is that in (lambda (v) (save-history) (old v)), (old v) — the call that
actually performs the exit — comes after save-history. When the first link raises, the
whole rest of the handler chain is dead.
Why this looks like "back at the prompt" rather than a crash: exit is an ordinary procedure
call, so the exception propagates up to the REPL's error handler, which prints it and
continues the next read-eval-print cycle. The process is entirely healthy — it just cannot
quit.
The same function has two more unguarded call sites; Ctrl-D goes through this path:
;; pread.rkt:186 — the input port's close procedure
(define (close!) (set! closed? #t) (save-history))
;; pread.rkt:199 — the callback for reaching eof
(if (eof-object? buf)
(save-history)
...)
3.4 Why the lock never heals itself
In 'exists mode the lock is the file's existence. There is no OS-level file lock and no
timeout or expiry whatsoever:
;; file.rkt:776-791
[else ; = 'exists
;; Only a write lock is needed, and the file lock
;; is implemented by the presence of the file:
(case kind
[(shared) (thunk)]
[(exclusive)
(with-handlers ([exn:fail:filesystem:exists?
(lambda (x) (failure-thunk))])
;; Grab lock:
(close-output-port (open-output-file lock-file #:exists 'error)))
(dynamic-wind
void
thunk
(lambda ()
;; Release lock:
(delete-file lock-file)))])]
Release happens only in the dynamic-wind post thunk (file.rkt:789-791). A process
killed by SIGKILL/SIGTERM leaves the file behind permanently — rebooting will not clear it.
Two further points worth noting:
-
put-preferencesnever retries. It callscall-with-file-lock(file.rkt:752),
not the backing-offcall-with-file-lock/timeout(file.rkt:698-729). One failed
acquisition and it raises. -
It blocks writes only, never reads. In the
'existsbranch,[(shared) (thunk)]
(file.rkt:779) runs unconditionally and never looks at the lock file. Measured: with the
lock present,get-preferencereturns the correct value in 1 ms. That is why the user only
ever meets this on exit — everything else looks fine.A corollary:
get-preference's exponential-backoff callback
(make-handle-get-preference-locked,file.rkt:873-890) is dead code on non-Windows
platforms, because the read path uses'shared(file.rkt:836-843) and the failure
thunk is never invoked.
3.5 Reproduction
;; exit-repro.rktl — uses an isolated preference file, never touches the real one
(require racket/file)
(define pf "/tmp/scratch/fake-prefs.rktd")
(define lf (make-lock-file-name pf))
(close-output-port (open-output-file lf #:exists 'truncate/replace))
;; exactly the shape used at pread.rkt:107-109
(exit-handler
(let ([old (exit-handler)])
(lambda (v) (put-preferences '(foo) '(1) #f pf) (old v))))
(printf "[step1] calling (exit 0) ...\n")
(exit 0)
(printf "[step1] >>> still alive: the exit was blocked by the lock <<<\n")
Output of racket -i < exit-repro.rktl:
[step1] calling (exit 0) ...
; put-preferences: some other process has the preference-file lock, as
; indicated by the existence of the lock file: ... [,bt for context]
[step1] >>> still alive: the exit was blocked by the lock <<<
The same thing with the real module (PLTUSERHOME pointed at an isolated directory, lock
pre-created):
$ racket -e '(require readline/pread)' -e '(displayln "work done")'
work done
put-preferences: some other process has the preference-file lock, ...
context...:
racket/collects/racket/file.rkt:752:0: call-with-file-lock
racket/share/pkgs/readline-lib/readline/pread.rkt:109:3 ← the exit handler
3.6 Fix (applied to the installed copy)
A single change to save-history, with three properties: never raise, bounded retry,
warn once.
(define-logger readline)
(define history-save-warned? #f)
;; `save-history' runs from the exit handler installed below, and from the
;; eof and close paths of the input port, so it must never raise: an
;; exception there aborts the exit itself, and at a REPL that leaves the
;; user with no way to quit. Warn instead -- but only once, since a lock
;; file left behind by a killed process never goes away on its own.
(define (history-not-saved why)
(log-readline-warning "input history not saved: ~a" why)
(unless history-save-warned?
(set! history-save-warned? #t)
(eprintf "readline: input history not saved: ~a\n" why)))
;; Unlike `get-preference', `put-preferences' does not retry when the lock
;; is busy, so back off a few times (~0.3s total) before giving up. The
;; `lock-there' callback must escape rather than return: `call-with-file-lock'
;; in `exists mode goes on to run the guarded write anyway once its failure
;; thunk returns normally.
(define (save-history)
(define (attempt delay)
(define busy-lock-file
(let/ec escape
(put-preferences '(readline-input-history) (list local-history)
(lambda (lock-file) (escape lock-file)))
#f))
(when busy-lock-file
(cond [(delay . < . 0.2)
(sleep delay)
(attempt (* 2 delay))]
[else
(history-not-saved
(format "~a is locked; delete it if no other Racket process is running"
busy-lock-file))])))
(with-handlers ([exn:fail? (lambda (e) (history-not-saved (exn-message e)))])
(attempt 0.01)))
Design notes:
- Backoff 0.01 → 0.16 over five attempts, +0.31 s worst case, paid only under genuine
contention; zero cost when uncontended. - The
lock-therecallback must escape rather than return normally — see §4 for why. exn:fail?excludesexn:break?, so Ctrl-C still propagates.- The warning goes two ways:
log-readline-warning(filterable via
PLTSTDERR="warning@readline") plus one line on stderr, at most once per process. stderr
was chosen because a stale lock can persist for days, and a log-only warning is invisible by
default — which would present as "history mysteriously stopped saving" with no clue. This
matches whatpreferences:put-preferences/guidoes (retry → tell the user →log-warning
instead of raising).
Verification:
| Scenario | Before | After |
|---|---|---|
Stale lock + REPL (exit 0) |
raises, back at prompt, cannot quit | exit status 0, one warning |
| Stale lock + batch | error plus context trace | one warning; prefs untouched and the other lock not deleted |
| No lock | fine | fine: other prefs preserved, history written back, lock released |
| Lock released after 0.05 s | fails outright | retry recovers, silent success (3/3) |
4. Defect B: Fall-Through in call-with-file-lock's 'exists Branch
4.1 Unexpected behavior
When a caller passes a lock-there (failure callback) that returns normally:
- The guarded action runs anyway after the acquisition fails — the lock is void.
- Another process's lock file is deleted, destroying its critical section.
- If the failure callback retries internally, then once one attempt succeeds, every outer
frame also runs the guarded action again. - The outer frames' release
delete-filehitsENOENT, raising an error with no relation to
the actual problem.
4.2 Relevant code
;; file.rkt:781-791
[(exclusive)
(with-handlers ([exn:fail:filesystem:exists?
(lambda (x) (failure-thunk))])
;; Grab lock:
(close-output-port (open-output-file lock-file #:exists 'error))) ; ← statement 1
(dynamic-wind
void
thunk
(lambda ()
;; Release lock:
(delete-file lock-file)))] ; ← statement 2
4.3 Trigger mechanism
The with-handlers (statement 1, acquire) and the dynamic-wind (statement 2, run the
guarded action) are two sequential statements in the case clause body, with no control
dependency between them.
When acquisition fails:
open-output-file #:exists 'error
└─► exn:fail:filesystem:exists?
└─► handler calls (failure-thunk)
└─► its return value is **discarded**
└─► control falls through to statement 2
└─► runs thunk (the write!)
└─► delete-file lock-file (deletes someone else's lock!)
The intended semantics are "run only if the lock was acquired; otherwise just call the failure
callback".
4.4 Why it is normally invisible
Because the default failure-thunk raises (file.rkt:739-748), and the exception carries
control out of the entire case clause, so statement 2 never gets a chance to run. The defect
only surfaces when a caller explicitly passes a lock-there that returns normally — which is
precisely the usage the documentation suggests for "give up quietly if the lock is busy"
(pass void).
4.5 Who is affected
- Any caller passing a normally-returning failure callback to
put-preferences/
get-preference/call-with-file-lock, including the most natural spelling,
(put-preferences names vals void). - Callers that retry inside the failure callback (as the first version of this fix did) also
hit behaviors 3 and 4. - Platform surface:
preferences-lock-file-modereturns'file-lockonly on Windows;
every other platform (macOS / Linux / BSD) takes this'existsbranch.
The read path is unaffected: 'shared runs [(shared) (thunk)] (file.rkt:779) and never
touches the lock file at all.
4.6 Reproduction
#lang racket/base
(require racket/file)
(define pf "/tmp/scratch/t-prefs.rktd")
(define lf (make-lock-file-name pf))
(when (file-exists? pf) (delete-file pf))
(close-output-port (open-output-file lf #:exists 'truncate/replace))
(printf "lock exists (before write): ~a\n" (file-exists? lf))
(define r (put-preferences '(b) '(2)
(lambda (l) (printf " [lock-there] called: could not take the lock\n") 'gave-up)
pf))
(printf "put-preferences returned: ~s\n" r)
(printf "lock exists (after write): ~a\n" (file-exists? lf))
(printf "prefs contents: ~s\n" (file->string pf))
Actual output:
lock exists (before write): #t
[lock-there] called: could not take the lock
put-preferences returned: #<void>
lock exists (after write): #f ← it deleted someone else's lock
prefs contents: "(\n (b 2)\n)\n" ← it wrote despite the lock
4.7 Proposed patch (not applied; pending a decision)
Turn the two parallel statements into a real control dependency:
[(exclusive)
(define got-lock?
(with-handlers ([exn:fail:filesystem:exists? (lambda (x) #f)])
;; Grab lock:
(close-output-port (open-output-file lock-file #:exists 'error))
#t))
(if got-lock?
(dynamic-wind
void
thunk
(lambda ()
;; Release lock:
(delete-file lock-file)))
(failure-thunk))]
Behavioral difference: the default path (where failure-thunk raises) is completely
unchanged; only the normally-returning-callback case has its semantics corrected to "do not
run the guarded action, do not delete another process's lock".
Note that this file is a core collection: the change affects every put-preferences /
get-preference / call-with-file-lock caller, and deserves careful review before landing.
5. Impact Summary
| Dimension | Defect A | Defect B |
|---|---|---|
| Location | readline-lib (separate upstream repo) |
racket/collects/racket/file.rkt (core, tracked here) |
| Platforms | non-Windows | non-Windows |
| Trigger | leftover lock file + readline loaded | caller passes a normally-returning lock-there |
| Consequence | the interpreter cannot exit | lock is void, others' locks deleted, duplicate writes, spurious ENOENT |
| Visible in default configuration | yes (deterministic) | no (the default callback raises, masking it) |
| Status | fixed (installed copy) | verified; patch pending a decision |
Operational remedy
Remove the leftover lock (after confirming no Racket process is currently writing preferences):
rm ~/Library/Preferences/.LOCKorg.racket-lang.prefs.rktd
Or escape in place from a REPL that is already stuck:
(require racket/file)
(delete-file (make-lock-file-name (find-system-path 'pref-file)))
(exit)
On how readline-lib is distributed
readline-lib is not under this repository's pkgs/; it comes from a separate upstream repo:
readline-lib 2eb66393029f7c98068bd0362ea79679560ff6ae
(catalog "readline-lib"
"git://github.com/racket/readline/?path=readline-lib#df55a811d5...")
So the fix in §3.6 edits the installed copy: racket/.gitignore:7 ignores all of
/share/, so git does not track it, and raco pkg update readline-lib will overwrite it. A
durable fix has to go upstream to racket/readline.
6. Code Index
| Location | Contents |
|---|---|
racket/collects/racket/file.rkt:693-696 |
preferences-lock-file-mode — the platform decides the lock semantics |
racket/collects/racket/file.rkt:698-729 |
call-with-file-lock/timeout — the backing-off variant (not used by put-preferences) |
racket/collects/racket/file.rkt:731-750 |
call-with-preference-file-lock — where the user-visible error text comes from |
racket/collects/racket/file.rkt:752-775 |
call-with-file-lock, 'file-lock branch (Windows only) |
racket/collects/racket/file.rkt:776-791 |
the 'exists branch — defect B lives here; 779 is the lock-ignoring read path |
racket/collects/racket/file.rkt:836-843 |
get-prefs takes the lock as 'shared |
racket/collects/racket/file.rkt:873-890 |
get-preference's exponential backoff (dead code on non-Windows) |
racket/collects/racket/file.rkt:919-1001 |
put-preferences — no state before acquisition, so escaping from lock-there is safe |
readline/pread.rkt:65-66 (original) |
save-history — defect A lives here |
readline/pread.rkt:107-109 |
puts save-history first in the exit-handler chain |
readline/pread.rkt:186, 199 |
the equally unguarded call sites in close! and the eof callback |
- Lenguaje dominante
- Racket
- Estrellas
- 5.2k
- Forks
- 708
- Merge medio
- 2 d 19 h
- PR fusionados (30 d)
- 12
Preparar el entorno
- Sin Dockerfile ni archivo de Docker Compose
- Tiene una plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de racket/racket
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
Los mantenedores suelen responder en 3 días
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 75/100
Los mantenedores suelen responder en 3 días
-
Scribble bug with solutionAbierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
racket/racket#5508 · 5 comentarios ·
Los mantenedores suelen responder en 3 días
-
Dificultad 5/5 Más de una semana Aptitud para principiantes 18/100
Los mantenedores suelen responder en 3 días
-
`raco exe` loses match submoduleAbierto
Dificultad 4/5 3-5 días Aptitud para principiantes 48/100
Los mantenedores suelen responder en 3 días
Todos los issues de racket/racket
Issues similares
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 62/100
Los mantenedores suelen responder en 3 días
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
dividebysandwich/rust-dos#434 ·
Los mantenedores suelen responder en 1 día
-
bug needs-triage
Dificultad 2/5 1-3 horas Aptitud para principiantes 88/100
Los mantenedores suelen responder en 1 día
-
area-System.IO untriaged
Dificultad 2/5 Medio día Aptitud para principiantes 72/100
dotnet/runtime#136802 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
Los mantenedores suelen responder en 1 día