Defect Analysis: The Preference-File Lock — Blocked Exit, and a Void Lock in `'exists` Mode

Abierto
#5,568 3 comentarios 0 reacciones 0 asignados Ver en GitHub

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

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:

  1. put-preferences never retries. It calls call-with-file-lock (file.rkt:752),
    not the backing-off call-with-file-lock/timeout (file.rkt:698-729). One failed
    acquisition and it raises.

  2. It blocks writes only, never reads. In the 'exists branch, [(shared) (thunk)]
    (file.rkt:779) runs unconditionally and never looks at the lock file. Measured: with the
    lock present, get-preference returns 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-there callback must escape rather than return normally — see §4 for why.
  • exn:fail? excludes exn: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 what preferences:put-preferences/gui does (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:

  1. The guarded action runs anyway after the acquisition fails — the lock is void.
  2. Another process's lock file is deleted, destroying its critical section.
  3. If the failure callback retries internally, then once one attempt succeeds, every outer
    frame also runs the guarded action again
    .
  4. The outer frames' release delete-file hits ENOENT, 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-mode returns 'file-lock only on Windows;
    every other platform (macOS / Linux / BSD) takes this 'exists branch.

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

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de racket/racket

Todos los issues de racket/racket

Issues similares

Más issues de Operating Systems

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.