From 991e4423aae0748f8c7c5dcc2af6770a66f065b6 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 25 Sep 2026 07:19:52 +0700 Subject: [PATCH 1/5] A dev session removes its build directory when it closes, and the next session sweeps the ones whose process is gone Every daemon left about 8MB in $TMPDIR/flan-dev-. A test_dev run left 300MB, and a few concurrent runs filled the tmpfs /tmp, after which every daemon died on its link before binding. The half-write test's abort also goes through [aborted] now, since an abort that worked can arrive as the socket closing. --- lib/dev.ml | 82 +++++++++++++++++++++++++++++++++++++++++------- test/test_dev.ml | 48 ++++++++++++++++++++++++++-- 2 files changed, 116 insertions(+), 14 deletions(-) diff --git a/lib/dev.ml b/lib/dev.ml index 693575a2..ca813039 100644 --- a/lib/dev.ml +++ b/lib/dev.ml @@ -4241,6 +4241,72 @@ let accept_loop ?grace t ls = in go () +(* ── The session's directory ───────────────────────────────────────── *) + +(* Every session builds into [$TMPDIR/flan-dev-]: the host executable, + its IR or listing, one module per evaluation. About 8MB before the first + evaluation, and nothing used to remove it, so every daemon that ever ran + left one behind. On a machine whose /tmp is a tmpfs that is memory, and a + test run starts some forty daemons: enough of them at once filled /tmp, and + the daemons after that died before binding, on the link, with "No space + left on device" — the "fail to bind under load" flake. + + Two halves. A session that ends through [close] removes its own directory + on the way out. One that does not — a signal, a program that died where it + stood through [_exit], a failed build — cannot, so the next session to start + removes every [flan-dev-] whose process no longer exists. A pid that + is alive keeps its directory whoever it is now: a reused pid costs one + directory kept too long, never one removed from under a session. + + Only ESRCH counts as gone. EPERM is a live process that belongs to someone + else, and a directory in a shared /tmp that is not ours fails to remove + anyway. *) +let dir_prefix = "flan-dev-" + +let session_dir_of pid = + Filename.concat (Filename.get_temp_dir_name ()) + (Printf.sprintf "%s%d" dir_prefix pid) + +(* Best effort throughout: what cannot be removed stays. The chmod is for a + directory a session left read-only, which test_dev's cannot-write-a-module + case does on purpose. *) +let rec remove_tree path = + match Unix.lstat path with + | { Unix.st_kind = Unix.S_DIR; _ } -> + (try Unix.chmod path 0o700 with Unix.Unix_error _ -> ()); + (match Sys.readdir path with + | names -> Array.iter (fun n -> remove_tree (Filename.concat path n)) names + | exception Sys_error _ -> ()); + (try Unix.rmdir path with Unix.Unix_error _ -> ()) + | _ -> (try Unix.unlink path with Unix.Unix_error _ -> ()) + | exception Unix.Unix_error _ -> () + +let sweep_dead_sessions () = + let tmp = Filename.get_temp_dir_name () in + let own = Unix.getpid () and plen = String.length dir_prefix in + match Sys.readdir tmp with + | exception Sys_error _ -> () + | names -> + Array.iter + (fun n -> + if String.length n > plen && String.sub n 0 plen = dir_prefix then + match int_of_string_opt (String.sub n plen (String.length n - plen)) with + | Some pid when pid > 0 && pid <> own -> + (match Unix.kill pid 0 with + | () -> () + | exception Unix.Unix_error (Unix.ESRCH, _, _) -> + remove_tree (Filename.concat tmp n) + | exception Unix.Unix_error _ -> ()) + | _ -> ()) + names + +(* This process's directory, after clearing out the dead ones. *) +let session_dir () = + sweep_dead_sessions (); + let dir = session_dir_of (Unix.getpid ()) in + (try Unix.mkdir dir 0o700 with Unix.Unix_error (Unix.EEXIST, _, _) -> ()); + dir + (* [debug] is off by default, which keeps [flan dev] exactly what it was: a -O2 host and -O2 modules. It is opt-in rather than always-on because a debug build is an -O0 build — [llvm.dbg.declare] describes an alloca and mem2reg @@ -4256,11 +4322,7 @@ let two_process ?(debug = false) ?(x86 = true) ~file ~sock () = directory it was relative to. *) let file = try Unix.realpath file with Unix.Unix_error _ -> file in let session, l = Session.create ~debug ~x86 ~file () in - let dir = - Filename.concat (Filename.get_temp_dir_name ()) - (Printf.sprintf "flan-dev-%d" (Unix.getpid ())) - in - (try Unix.mkdir dir 0o700 with Unix.Unix_error (Unix.EEXIST, _, _) -> ()); + let dir = session_dir () in let exe = Filename.concat dir "program" in (* [keep] so the host's own IR survives the build. It is the text [llc] was actually given, not a second emission of it, which is the difference @@ -4351,7 +4413,8 @@ let two_process ?(debug = false) ?(x86 = true) ~file ~sock () = (try Unix.kill child Sys.sigterm with Unix.Unix_error _ -> ()); (try Unix.close ls with Unix.Unix_error _ -> ()); (try Unix.close rd with Unix.Unix_error _ -> ()); - (try Unix.unlink sock with Unix.Unix_error _ -> ())) + (try Unix.unlink sock with Unix.Unix_error _ -> ()); + remove_tree dir) (fun () -> accept_loop t ls) (* ── One process: the program and the compiler in the same binary ──── *) @@ -5234,6 +5297,7 @@ let merged_serve () = Printf.eprintf "flan dev: %s\n%!" (Printexc.to_string e)); (try Unix.close ls with Unix.Unix_error _ -> ()); (try Unix.unlink sock with Unix.Unix_error _ -> ()); + remove_tree t.dir; (* [close] from the editor ends the session, and so now does an editor that stopped being there; in one process either means the program too — which is what the daemon did by killing its child. [_exit] for the loader-lock @@ -5250,11 +5314,7 @@ let start_merged ?(debug = false) ?(x86 = true) ~file ~sock () = let t0 = Unix.gettimeofday () in let file = try Unix.realpath file with Unix.Unix_error _ -> file in let session, l = Session.create ~debug ~x86 ~file () in - let dir = - Filename.concat (Filename.get_temp_dir_name ()) - (Printf.sprintf "flan-dev-%d" (Unix.getpid ())) - in - (try Unix.mkdir dir 0o700 with Unix.Unix_error (Unix.EEXIST, _, _) -> ()); + let dir = session_dir () in let exe = Filename.concat dir "program" in (* The host's IR goes straight to its final home rather than being written into the build's working directory and moved: the merged link is spelled diff --git a/test/test_dev.ml b/test/test_dev.ml index 5be18307..7ee6b241 100644 --- a/test/test_dev.ml +++ b/test/test_dev.ml @@ -216,6 +216,33 @@ let () = rewrites: a true statement about the backend and no test of the verb. The default itself is checked further down, on a daemon that does not need frames. *) + (* A session builds into [$TMPDIR/flan-dev-], about 8MB before its + first evaluation. Left behind by every daemon, those filled a tmpfs /tmp + under a few concurrent runs of this file, and every daemon after that + died on its link before binding. So: a dead session's directory is + swept by the next session to start, a live one's is not, and a session + that ends through [close] takes its own with it. The dead pid is a + process this test ran and reaped; this test's own pid is the live one. *) + let session_dir p = + Filename.concat (Filename.get_temp_dir_name ()) + (Printf.sprintf "flan-dev-%d" p) + in + let dead = + let p = + Unix.create_process "true" [| "true" |] Unix.stdin Unix.stdout + Unix.stderr + in + ignore (Unix.waitpid [] p); + p + in + let plant p = + let d = session_dir p in + (try Unix.mkdir d 0o700 with Unix.Unix_error (Unix.EEXIST, _, _) -> ()); + Out_channel.with_open_bin (Filename.concat d "program") (fun oc -> + output_string oc "left behind") + in + plant dead; + plant (Unix.getpid ()); let pid = Unix.create_process flan [| flan; "dev"; "programs/dev-loop.flan"; "-s"; sock; "--llvm" |] @@ -226,6 +253,15 @@ let () = if not (listening ~pid sock) then fail "the daemon %s" !listen_why else begin + if Sys.file_exists (session_dir dead) then + fail "a dead session's directory %s outlived the next session's start" + (session_dir dead); + if not (Sys.file_exists (session_dir (Unix.getpid ()))) then + fail "a live process's session directory was swept"; + (try Sys.remove (Filename.concat (session_dir (Unix.getpid ())) "program") + with Sys_error _ -> ()); + (try Unix.rmdir (session_dir (Unix.getpid ())) + with Unix.Unix_error _ -> ()); (* The daemon owns the program's lifetime and kills it on [close], so every step waits for the program to have got there. "ok" from an eval means the module was queued, not that it has been installed. *) @@ -797,6 +833,9 @@ let () = transcript is the claim — after everything the first run printed and before anything the second did. *) ignore (Unix.waitpid [] pid); + if Sys.file_exists (session_dir pid) then + fail "a session that ended through close left %s behind" + (session_dir pid); let text = Buffer.contents output in let wanted = "1\n5\n105\n777\npk\n106\n" in if text <> wanted then @@ -5605,9 +5644,12 @@ let () = abort is refused by a program that is *running*, which is what a failure above would leave behind — and this fixture then polls for twenty seconds and parks, so the wait below would be a hang rather - than a report. The signal is for that case only. *) - if status (request c "(:op \"abort\")") <> "ok" then - (try Unix.kill hpid Sys.sigkill with Unix.Unix_error _ -> ()); + than a report. The signal is for that case only. Through [aborted], + because an abort that worked can arrive as the socket closing. *) + (match aborted c with + | Some r when status r <> "ok" -> + (try Unix.kill hpid Sys.sigkill with Unix.Unix_error _ -> ()) + | _ -> ()); (try Unix.close c with Unix.Unix_error _ -> ()); (try ignore (Unix.waitpid [] hpid) with Unix.Unix_error _ -> ()) end; From 2250548b0773e6e59d3cee04c7cda16031448cf7 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 25 Sep 2026 07:21:44 +0700 Subject: [PATCH 2/5] A test that sees a process die on a signal names the signal, not OCaml's number for it OCaml numbers the signals negatively and in its own order: SIGTERM is -11. The globals daemon's "transient signal 11" was a SIGTERM, not a segfault. --- test/test_acceptance.ml | 8 ++++---- test/test_agent.ml | 12 ++++++------ test/test_dev.ml | 21 +++++++++++++++++++-- test/test_emacs.ml | 7 ++++--- test/test_repl.ml | 6 ++++-- test/test_support.ml | 22 ++++++++++++++++++++-- 6 files changed, 57 insertions(+), 19 deletions(-) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 58a8cf4e..283af120 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -186,8 +186,8 @@ module Pool = struct name (match status with | Unix.WEXITED n -> Printf.sprintf "with code %d" n - | Unix.WSIGNALED n -> Printf.sprintf "on signal %d" n - | Unix.WSTOPPED n -> Printf.sprintf "stopped on signal %d" n)) + | Unix.WSIGNALED n -> "on " ^ Test_support.signal_name n + | Unix.WSTOPPED n -> "stopped on " ^ Test_support.signal_name n)) | None, _ -> Some (Printf.sprintf @@ -195,8 +195,8 @@ module Pool = struct name (match status with | Unix.WEXITED n -> Printf.sprintf "exit %d" n - | Unix.WSIGNALED n -> Printf.sprintf "signal %d" n - | Unix.WSTOPPED n -> Printf.sprintf "stopped, signal %d" n)) + | Unix.WSIGNALED n -> Test_support.signal_name n + | Unix.WSTOPPED n -> "stopped on " ^ Test_support.signal_name n)) in ignore (Queue.pop inflight); match msg with diff --git a/test/test_agent.ml b/test/test_agent.ml index 62a24bef..4d04b60a 100644 --- a/test/test_agent.ml +++ b/test/test_agent.ml @@ -149,8 +149,8 @@ let () = fail "agent reload\n got: %S (%s)\n wanted: %S" text (match status with | Unix.WEXITED c -> Printf.sprintf "exit %d" c - | Unix.WSIGNALED c -> Printf.sprintf "signal %d" c - | Unix.WSTOPPED c -> Printf.sprintf "stopped %d" c) + | Unix.WSIGNALED c -> Test_support.signal_name c + | Unix.WSTOPPED c -> "stopped on " ^ Test_support.signal_name c) "1\n1000\n1007\n" end; @@ -309,8 +309,8 @@ let () = %S (exit 1)" text (match !bstat with | Unix.WEXITED c -> Printf.sprintf "exit %d" c - | Unix.WSIGNALED c -> Printf.sprintf "signal %d" c - | Unix.WSTOPPED c -> Printf.sprintf "stopped %d" c) + | Unix.WSIGNALED c -> Test_support.signal_name c + | Unix.WSTOPPED c -> "stopped on " ^ Test_support.signal_name c) "cannot listen\n" end; @@ -766,8 +766,8 @@ let () = fail "abort left status %s, wanted exit 134" (match !lstatus with | Unix.WEXITED c -> Printf.sprintf "exit %d" c - | Unix.WSIGNALED c -> Printf.sprintf "signal %d" c - | Unix.WSTOPPED c -> Printf.sprintf "stopped %d" c) + | Unix.WSIGNALED c -> Test_support.signal_name c + | Unix.WSTOPPED c -> "stopped on " ^ Test_support.signal_name c) (* The other way out, and the one that skips atexit on purpose: [abort] leaves by [_exit] so that it cannot hang on the loader lock, and the socket is therefore unlinked by hand there. A file left here would diff --git a/test/test_dev.ml b/test/test_dev.ml index 7ee6b241..83387be3 100644 --- a/test/test_dev.ml +++ b/test/test_dev.ml @@ -196,6 +196,23 @@ let () = if not (contains_sub other "flan.abi.x86") then fail "a parked program's other refusals were rewritten too: %S" other +(* A daemon's death is reported by the signal's name. [WSIGNALED] carries + OCaml's own numbering, in which SIGTERM is -11, and a SIGTERM printed as + "signal -11" was once recorded as a segfault. A real process, really + terminated, so the number is whatever [waitpid] hands back. *) +let () = + let p = + Unix.create_process "sleep" [| "sleep"; "30" |] Unix.stdin Unix.stdout + Unix.stderr + in + Unix.kill p Sys.sigterm; + match Unix.waitpid [] p with + | _, Unix.WSIGNALED n when Test_support.signal_name n = "SIGTERM" -> () + | _, Unix.WSIGNALED n -> + fail "a process ended by SIGTERM is reported as %s" + (Test_support.signal_name n) + | _, _ -> fail "a process sent SIGTERM did not end on a signal" + let () = match Sys.command "command -v clang > /dev/null 2>&1 && command -v llc > /dev/null 2>&1" with | 0 -> @@ -5143,8 +5160,8 @@ let () = (match st with | Unix.WEXITED n -> Printf.sprintf "exit %d" n | Unix.WSIGNALED n when n = Sys.sigpipe -> "killed by SIGPIPE" - | Unix.WSIGNALED n -> Printf.sprintf "signal %d" n - | Unix.WSTOPPED n -> Printf.sprintf "stopped on %d" n); + | Unix.WSIGNALED n -> Test_support.signal_name n + | Unix.WSTOPPED n -> "stopped on " ^ Test_support.signal_name n); None | exception Unix.Unix_error _ -> Some (connect rsock) in diff --git a/test/test_emacs.ml b/test/test_emacs.ml index ecee324d..c2b2ebe1 100644 --- a/test/test_emacs.ml +++ b/test/test_emacs.ml @@ -112,9 +112,10 @@ let () = match !status with | Some (Unix.WEXITED n) -> Printf.sprintf "exited with status %d" n | Some (Unix.WSIGNALED n) -> - Printf.sprintf "was killed by signal %d — nothing ran its cleanup, so \ - the socket file is stale rather than gone" n - | Some (Unix.WSTOPPED n) -> Printf.sprintf "stopped on signal %d" n + Printf.sprintf "was killed by %s — nothing ran its cleanup, so \ + the socket file is stale rather than gone" + (Test_support.signal_name n) + | Some (Unix.WSTOPPED n) -> Printf.sprintf "stopped on %s" (Test_support.signal_name n) | None -> "was still running when the client finished, and was terminated" in (* Read before the removal below, because on a failure this is the evidence diff --git a/test/test_repl.ml b/test/test_repl.ml index 9579130f..752b0679 100644 --- a/test/test_repl.ml +++ b/test/test_repl.ml @@ -326,9 +326,11 @@ let () = "the daemon had already been killed by SIGPIPE — a reply written \ into a socket whose reader had gone" | Some (Unix.WSIGNALED n) -> - Printf.printf "the daemon had already been killed by signal %d\n" n + Printf.printf "the daemon had already been killed by %s\n" + (Test_support.signal_name n) | Some (Unix.WSTOPPED n) -> - Printf.printf "the daemon was stopped on signal %d\n" n + Printf.printf "the daemon was stopped on %s\n" + (Test_support.signal_name n) | None -> print_endline "the daemon was still running at the end"); Printf.printf "\n-- the program's own output, last 4k --\n%s\n" prog_out; exit 1 diff --git a/test/test_support.ml b/test/test_support.ml index 41228ca6..a64b73a7 100644 --- a/test/test_support.ml +++ b/test/test_support.ml @@ -152,6 +152,24 @@ let rec connect ?(ms = 5000) path = thing after a failed wait is always the report of it. *) let listen_why = ref "" +(* A signal as [WSIGNALED] carries it, by name. OCaml numbers the signals it + knows negatively and in its own order — [Sys.sigterm] is -11 and + [Sys.sigsegv] is -10 — so printing the number reads as the wrong signal to + anyone who knows the POSIX table: a daemon terminated by SIGTERM was once + recorded here as "a transient signal 11", a segfault that never happened. *) +let signal_name n = + let known = + [ Sys.sigabrt, "SIGABRT"; Sys.sigalrm, "SIGALRM"; Sys.sigfpe, "SIGFPE"; + Sys.sighup, "SIGHUP"; Sys.sigill, "SIGILL"; Sys.sigint, "SIGINT"; + Sys.sigkill, "SIGKILL"; Sys.sigpipe, "SIGPIPE"; Sys.sigquit, "SIGQUIT"; + Sys.sigsegv, "SIGSEGV"; Sys.sigterm, "SIGTERM"; Sys.sigusr1, "SIGUSR1"; + Sys.sigusr2, "SIGUSR2"; Sys.sigchld, "SIGCHLD"; Sys.sigbus, "SIGBUS"; + Sys.sigtrap, "SIGTRAP"; Sys.sigxcpu, "SIGXCPU" ] + in + match List.assoc_opt n known with + | Some s -> s + | None -> Printf.sprintf "signal %d" n + let listening ?(ms = 30000) ~pid path = let died = ref None in ignore @@ -171,10 +189,10 @@ let listening ?(ms = 30000) ~pid path = | Some (Unix.WEXITED n) -> Printf.sprintf "exited with status %d before binding %s" n path | Some (Unix.WSIGNALED n) -> - Printf.sprintf "was killed by signal %d before binding %s" n path + Printf.sprintf "was killed by %s before binding %s" (signal_name n) path (* Unreachable without WUNTRACED, and here only for exhaustiveness. *) | Some (Unix.WSTOPPED n) -> - Printf.sprintf "stopped on signal %d without binding %s" n path + Printf.sprintf "stopped on %s without binding %s" (signal_name n) path | None -> Printf.sprintf "was still running after %ds without binding %s, so it was the \ From 04f5d5a3493465ea9876420a2515ece3fe7b6256 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 25 Sep 2026 07:22:42 +0700 Subject: [PATCH 3/5] Under Evil, a digit in the break buffer takes the restart, because the buffer's keys take precedence over Evil's --- emacs/flan-cnr.el | 8 ++++++++ emacs/test-flan-cider.el | 41 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/emacs/flan-cnr.el b/emacs/flan-cnr.el index 72b3dbcd..6b2ffb75 100644 --- a/emacs/flan-cnr.el +++ b/emacs/flan-cnr.el @@ -702,6 +702,14 @@ anyone who would rather TAB always moved." map) "Keys in `flan-cnr-mode'.") +;; Evil's normal state binds 0, the other digits and RET above any major +;; mode's map, so under Evil a digit moved point or started a count and took +;; nothing. This map takes precedence over Evil's state maps instead; the keys +;; it does not bind, j and k among them, are still Evil's. +(with-eval-after-load 'evil + (when (fboundp 'evil-make-overriding-map) + (evil-make-overriding-map flan-cnr-mode-map))) + (define-derived-mode flan-cnr-mode special-mode "flan-break" "What a stopped Flan program is offering." (setq buffer-read-only t)) diff --git a/emacs/test-flan-cider.el b/emacs/test-flan-cider.el index a0247452..aacc92d5 100644 --- a/emacs/test-flan-cider.el +++ b/emacs/test-flan-cider.el @@ -1704,6 +1704,47 @@ stopped program, which is the case where it should fire." (file-name-directory load-file-name)) nil t) +;; The break buffer's keys under Evil, pressed through the command loop rather +;; than called. Evil's normal state binds 0 (beginning of line), 1-9 (a count) +;; and RET (next line) above any major mode's map, so without the buffer's map +;; taking precedence a digit moved point and took nothing. Run only where +;; Evil is installed: -Q loads no packages, so it is looked for in the two +;; places a package install puts it, and turned off again afterwards so nothing +;; above or below this runs under it. +(let* ((dirs (append (file-expand-wildcards "~/.config/emacs/elpa/evil-[0-9]*") + (file-expand-wildcards "~/.emacs.d/elpa/evil-[0-9]*") + (file-expand-wildcards "~/.config/emacs/elpa/goto-chg-*") + (file-expand-wildcards "~/.emacs.d/elpa/goto-chg-*"))) + (load-path (append dirs load-path))) + (if (not (require 'evil nil t)) + (message " skip the break buffer under Evil (Evil is not installed)") + (evil-mode 1) + (unwind-protect + (let* ((sent nil) + (flan-cnr-request-function + (lambda (form) (setq sent form) (list :status "ok"))) + (buf (test-flan--cnr + (list :condition "Missing" :restarts '("retry" "skip"))))) + (switch-to-buffer buf) + (evil-initialize-state) + (goto-char (point-min)) + (execute-kbd-macro (kbd "0")) + (test-flan--check "under Evil, 0 takes restart 0" + (equal sent '(:op "restart-at" :index 0 :name "retry"))) + (setq sent nil) + (switch-to-buffer buf) + (execute-kbd-macro (kbd "1")) + (test-flan--check "under Evil, 1 takes restart 1" + (equal sent '(:op "restart-at" :index 1 :name "skip"))) + (setq sent nil) + (switch-to-buffer buf) + (goto-char (point-min)) + (search-forward " 1: ") + (execute-kbd-macro (kbd "RET")) + (test-flan--check "under Evil, RET takes the restart on its line" + (equal sent '(:op "restart-at" :index 1 :name "skip")))) + (evil-mode -1)))) + (message "\n%d checks, %d failures" test-flan--ran test-flan--failures) (kill-emacs (if (> test-flan--failures 0) 1 0)) From 5477c759090739a3ea8ae4611031be9624adbbee Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 25 Sep 2026 07:24:20 +0700 Subject: [PATCH 4/5] Three of the four under-load and dogfooding entries are settled, and the fourth records what was tried --- TODO.org | 44 +++++++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/TODO.org b/TODO.org index fcf9257b..148b42ed 100644 --- a/TODO.org +++ b/TODO.org @@ -1631,16 +1631,23 @@ sanitizer flag to it. Named as the check worth adding next; a day rather than an hour. The x86 backend is not a gap here — that pair is refused by name, because there is no sanitizer pass over hand-written assembly. -** TODO A transient signal 11 on a globals daemon -Seen once, never reproduced, on a daemon whose fixture had just gained a host -global =Vec= and a run-time-new one. A reproduction under load would settle it. +** DONE A transient signal 11 on a globals daemon +CLOSED: [2026-09-25] +Not a segfault. The report was OCaml's signal number, and in OCaml's numbering +-11 is SIGTERM (SIGSEGV is -10). Nothing in the daemon sends itself SIGTERM, so +it was killed from outside. The test binaries now print a signal by name +(=Test_support.signal_name=); a number from =WSIGNALED= is never printed raw. -** TODO test_dev daemons fail to bind under load -Daemons exiting with status 1 or 2 before binding their socket, across most of the -file at once, while the load average is high. No stale socket or leftover daemon -afterwards; green on a quiet machine. Distinct from the registry race and the -stale-park re-run flake, both of which are fixed. A daemon that dies before it -binds died on the compiler's side, before any program it built has run a line. +** DONE test_dev daemons fail to bind under load +CLOSED: [2026-09-25] +/tmp is a tmpfs, and every session left its =flan-dev-= build directory +behind (about 8MB; a test_dev run left 300MB). Several concurrent runs filled +it and the daemons after that died on the link with ENOSPC. A session now +removes its directory when it ends through =close=, and each new session +removes the directories of pids that no longer exist. A live pid's directory is +never touched, so a failed build's IR stays until the next session starts. +The half-write test's abort also goes through =aborted= now, which took the +same run down with an uncaught =Wire.Closed=. ** DONE A test binary that hangs is killed by its own alarm A reader branch that forgets to advance loops for ever and the suite waits as long @@ -1781,13 +1788,20 @@ function of that name. Not reproduced: the file type checks, =flan reload= of the same form builds, and a minimal =defclass= + =get= + =return= program compiles. So it is the session path against an installed program, and what is missing is what that daemon had installed at the time. +Also not reproduced against a live daemon (2026-09-25): an =eval= of the +=defclass=, of a =defn= doing the =get= and =return=, and of both in one form, +and an =eval-expr= of the =get=, all succeed. In a session every installed +function of no arguments returning =()= has the type =(CFn [] ())=, not only +=pause= — a bare =pause= or =tick= asked of the session says so — so the +keyword may not be what resolved. The next report wants the exact form sent. -** TODO A digit does not take the restart RET takes -Pressing =0= left the program stopped; RET on the same line resumed it. Both -end in =flan-cnr-take=, but the digit path (=flan-cnr-take-number=, -=flan-cnr.el:559=) scans from =point-min= for the line whose =flan-cnr-index= -matches and calls =take= inside a =save-excursion=. Not reproduced yet — needs -a non-raylib program stopped under a test daemon. +** DONE A digit does not take the restart RET takes +CLOSED: [2026-09-25] +Evil's normal state binds =0= (beginning of line), =1=-=9= (a count) and RET +above the major mode's map, so the digit never reached =flan-cnr-take-number=. +=flan-cnr-mode-map= is now an Evil overriding map; keys it does not bind stay +Evil's. The other special-mode buffers (inspect, watch, doc, disassembly, +diagnostics, lower) have the same exposure and are not changed. ** TODO Eval in the frame, from the break loop An expression is evaluated at a frame boundary, so it sees globals and not the From 414e1e5ae6e999061e1d9fcb7cd4b6fb63f46e31 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 25 Sep 2026 07:40:33 +0700 Subject: [PATCH 5/5] The temp directory is left to the build-plumbing lane, and under Evil the break buffer takes only the keys it binds The dead-session sweep and the remove-on-close half are dropped: the sweep contradicts the recorded non-goals, and the other lane owns removal on a clean close. The Evil keys are defined per state from the map's own bindings, so special-mode-map's h, SPC, < and - no longer shadow Evil's. --- TODO.org | 20 +++++----- emacs/flan-cnr.el | 18 +++++++-- emacs/test-flan-cider.el | 10 ++++- lib/dev.ml | 82 ++++++---------------------------------- test/test_dev.ml | 39 ------------------- 5 files changed, 44 insertions(+), 125 deletions(-) diff --git a/TODO.org b/TODO.org index 148b42ed..456f723c 100644 --- a/TODO.org +++ b/TODO.org @@ -1640,14 +1640,13 @@ it was killed from outside. The test binaries now print a signal by name ** DONE test_dev daemons fail to bind under load CLOSED: [2026-09-25] -/tmp is a tmpfs, and every session left its =flan-dev-= build directory -behind (about 8MB; a test_dev run left 300MB). Several concurrent runs filled -it and the daemons after that died on the link with ENOSPC. A session now -removes its directory when it ends through =close=, and each new session -removes the directories of pids that no longer exist. A live pid's directory is -never touched, so a failed build's IR stays until the next session starts. -The half-write test's abort also goes through =aborted= now, which took the -same run down with an uncaught =Wire.Closed=. +The failure was a full /tmp, not the load. /tmp is a tmpfs, and every session +left its =flan-dev-= build directory behind (a test_dev run leaves about +300MB); a few concurrent runs filled it and each daemon after that died on its +link with ENOSPC. The fix is the session removing its own directory on a clean +=close=, which the build-plumbing lane owns. The half-write test's abort also +goes through =aborted= now, which took the same run down with an uncaught +=Wire.Closed=. ** DONE A test binary that hangs is killed by its own alarm A reader branch that forgets to advance loops for ever and the suite waits as long @@ -1799,8 +1798,9 @@ keyword may not be what resolved. The next report wants the exact form sent. CLOSED: [2026-09-25] Evil's normal state binds =0= (beginning of line), =1=-=9= (a count) and RET above the major mode's map, so the digit never reached =flan-cnr-take-number=. -=flan-cnr-mode-map= is now an Evil overriding map; keys it does not bind stay -Evil's. The other special-mode buffers (inspect, watch, doc, disassembly, +The keys =flan-cnr-mode-map= itself binds are given to Evil's normal and +motion states in that mode; every other key, including what =special-mode-map= +binds, stays Evil's. The other special-mode buffers (inspect, watch, doc, disassembly, diagnostics, lower) have the same exposure and are not changed. ** TODO Eval in the frame, from the break loop diff --git a/emacs/flan-cnr.el b/emacs/flan-cnr.el index 6b2ffb75..dea14019 100644 --- a/emacs/flan-cnr.el +++ b/emacs/flan-cnr.el @@ -704,11 +704,21 @@ anyone who would rather TAB always moved." ;; Evil's normal state binds 0, the other digits and RET above any major ;; mode's map, so under Evil a digit moved point or started a count and took -;; nothing. This map takes precedence over Evil's state maps instead; the keys -;; it does not bind, j and k among them, are still Evil's. +;; nothing. The keys this file binds are given to Evil's normal and motion +;; states in this mode. Only those keys: `flan-cnr-mode-map' inherits +;; `special-mode-map', and making it an overriding map would carry h, SPC, < +;; and - over from there as well. Every key not listed stays Evil's. (with-eval-after-load 'evil - (when (fboundp 'evil-make-overriding-map) - (evil-make-overriding-map flan-cnr-mode-map))) + (when (fboundp 'evil-define-key*) + ;; Collected first, and without the parent's bindings, because + ;; `evil-define-key*' writes into the map being walked. + (let ((own nil)) + (map-keymap-internal (lambda (key def) + (when (commandp def) (push (cons key def) own))) + flan-cnr-mode-map) + (dolist (b own) + (evil-define-key* '(normal motion) flan-cnr-mode-map + (vector (car b)) (cdr b)))))) (define-derived-mode flan-cnr-mode special-mode "flan-break" "What a stopped Flan program is offering." diff --git a/emacs/test-flan-cider.el b/emacs/test-flan-cider.el index aacc92d5..a13ead5d 100644 --- a/emacs/test-flan-cider.el +++ b/emacs/test-flan-cider.el @@ -1742,7 +1742,15 @@ stopped program, which is the case where it should fire." (search-forward " 1: ") (execute-kbd-macro (kbd "RET")) (test-flan--check "under Evil, RET takes the restart on its line" - (equal sent '(:op "restart-at" :index 1 :name "skip")))) + (equal sent '(:op "restart-at" :index 1 :name "skip"))) + ;; And the keys the buffer does not bind are still Evil's, not the + ;; ones `special-mode-map' would bring with it. + (switch-to-buffer buf) + (dolist (k '(("h" . evil-backward-char) ("SPC" . evil-forward-char) + ("<" . evil-shift-left) + ("-" . evil-previous-line-first-non-blank))) + (test-flan--check (format "under Evil, %s is still Evil's" (car k)) + (eq (key-binding (kbd (car k))) (cdr k))))) (evil-mode -1)))) (message "\n%d checks, %d failures" test-flan--ran test-flan--failures) diff --git a/lib/dev.ml b/lib/dev.ml index ca813039..693575a2 100644 --- a/lib/dev.ml +++ b/lib/dev.ml @@ -4241,72 +4241,6 @@ let accept_loop ?grace t ls = in go () -(* ── The session's directory ───────────────────────────────────────── *) - -(* Every session builds into [$TMPDIR/flan-dev-]: the host executable, - its IR or listing, one module per evaluation. About 8MB before the first - evaluation, and nothing used to remove it, so every daemon that ever ran - left one behind. On a machine whose /tmp is a tmpfs that is memory, and a - test run starts some forty daemons: enough of them at once filled /tmp, and - the daemons after that died before binding, on the link, with "No space - left on device" — the "fail to bind under load" flake. - - Two halves. A session that ends through [close] removes its own directory - on the way out. One that does not — a signal, a program that died where it - stood through [_exit], a failed build — cannot, so the next session to start - removes every [flan-dev-] whose process no longer exists. A pid that - is alive keeps its directory whoever it is now: a reused pid costs one - directory kept too long, never one removed from under a session. - - Only ESRCH counts as gone. EPERM is a live process that belongs to someone - else, and a directory in a shared /tmp that is not ours fails to remove - anyway. *) -let dir_prefix = "flan-dev-" - -let session_dir_of pid = - Filename.concat (Filename.get_temp_dir_name ()) - (Printf.sprintf "%s%d" dir_prefix pid) - -(* Best effort throughout: what cannot be removed stays. The chmod is for a - directory a session left read-only, which test_dev's cannot-write-a-module - case does on purpose. *) -let rec remove_tree path = - match Unix.lstat path with - | { Unix.st_kind = Unix.S_DIR; _ } -> - (try Unix.chmod path 0o700 with Unix.Unix_error _ -> ()); - (match Sys.readdir path with - | names -> Array.iter (fun n -> remove_tree (Filename.concat path n)) names - | exception Sys_error _ -> ()); - (try Unix.rmdir path with Unix.Unix_error _ -> ()) - | _ -> (try Unix.unlink path with Unix.Unix_error _ -> ()) - | exception Unix.Unix_error _ -> () - -let sweep_dead_sessions () = - let tmp = Filename.get_temp_dir_name () in - let own = Unix.getpid () and plen = String.length dir_prefix in - match Sys.readdir tmp with - | exception Sys_error _ -> () - | names -> - Array.iter - (fun n -> - if String.length n > plen && String.sub n 0 plen = dir_prefix then - match int_of_string_opt (String.sub n plen (String.length n - plen)) with - | Some pid when pid > 0 && pid <> own -> - (match Unix.kill pid 0 with - | () -> () - | exception Unix.Unix_error (Unix.ESRCH, _, _) -> - remove_tree (Filename.concat tmp n) - | exception Unix.Unix_error _ -> ()) - | _ -> ()) - names - -(* This process's directory, after clearing out the dead ones. *) -let session_dir () = - sweep_dead_sessions (); - let dir = session_dir_of (Unix.getpid ()) in - (try Unix.mkdir dir 0o700 with Unix.Unix_error (Unix.EEXIST, _, _) -> ()); - dir - (* [debug] is off by default, which keeps [flan dev] exactly what it was: a -O2 host and -O2 modules. It is opt-in rather than always-on because a debug build is an -O0 build — [llvm.dbg.declare] describes an alloca and mem2reg @@ -4322,7 +4256,11 @@ let two_process ?(debug = false) ?(x86 = true) ~file ~sock () = directory it was relative to. *) let file = try Unix.realpath file with Unix.Unix_error _ -> file in let session, l = Session.create ~debug ~x86 ~file () in - let dir = session_dir () in + let dir = + Filename.concat (Filename.get_temp_dir_name ()) + (Printf.sprintf "flan-dev-%d" (Unix.getpid ())) + in + (try Unix.mkdir dir 0o700 with Unix.Unix_error (Unix.EEXIST, _, _) -> ()); let exe = Filename.concat dir "program" in (* [keep] so the host's own IR survives the build. It is the text [llc] was actually given, not a second emission of it, which is the difference @@ -4413,8 +4351,7 @@ let two_process ?(debug = false) ?(x86 = true) ~file ~sock () = (try Unix.kill child Sys.sigterm with Unix.Unix_error _ -> ()); (try Unix.close ls with Unix.Unix_error _ -> ()); (try Unix.close rd with Unix.Unix_error _ -> ()); - (try Unix.unlink sock with Unix.Unix_error _ -> ()); - remove_tree dir) + (try Unix.unlink sock with Unix.Unix_error _ -> ())) (fun () -> accept_loop t ls) (* ── One process: the program and the compiler in the same binary ──── *) @@ -5297,7 +5234,6 @@ let merged_serve () = Printf.eprintf "flan dev: %s\n%!" (Printexc.to_string e)); (try Unix.close ls with Unix.Unix_error _ -> ()); (try Unix.unlink sock with Unix.Unix_error _ -> ()); - remove_tree t.dir; (* [close] from the editor ends the session, and so now does an editor that stopped being there; in one process either means the program too — which is what the daemon did by killing its child. [_exit] for the loader-lock @@ -5314,7 +5250,11 @@ let start_merged ?(debug = false) ?(x86 = true) ~file ~sock () = let t0 = Unix.gettimeofday () in let file = try Unix.realpath file with Unix.Unix_error _ -> file in let session, l = Session.create ~debug ~x86 ~file () in - let dir = session_dir () in + let dir = + Filename.concat (Filename.get_temp_dir_name ()) + (Printf.sprintf "flan-dev-%d" (Unix.getpid ())) + in + (try Unix.mkdir dir 0o700 with Unix.Unix_error (Unix.EEXIST, _, _) -> ()); let exe = Filename.concat dir "program" in (* The host's IR goes straight to its final home rather than being written into the build's working directory and moved: the merged link is spelled diff --git a/test/test_dev.ml b/test/test_dev.ml index 83387be3..fe2e9d6d 100644 --- a/test/test_dev.ml +++ b/test/test_dev.ml @@ -233,33 +233,6 @@ let () = rewrites: a true statement about the backend and no test of the verb. The default itself is checked further down, on a daemon that does not need frames. *) - (* A session builds into [$TMPDIR/flan-dev-], about 8MB before its - first evaluation. Left behind by every daemon, those filled a tmpfs /tmp - under a few concurrent runs of this file, and every daemon after that - died on its link before binding. So: a dead session's directory is - swept by the next session to start, a live one's is not, and a session - that ends through [close] takes its own with it. The dead pid is a - process this test ran and reaped; this test's own pid is the live one. *) - let session_dir p = - Filename.concat (Filename.get_temp_dir_name ()) - (Printf.sprintf "flan-dev-%d" p) - in - let dead = - let p = - Unix.create_process "true" [| "true" |] Unix.stdin Unix.stdout - Unix.stderr - in - ignore (Unix.waitpid [] p); - p - in - let plant p = - let d = session_dir p in - (try Unix.mkdir d 0o700 with Unix.Unix_error (Unix.EEXIST, _, _) -> ()); - Out_channel.with_open_bin (Filename.concat d "program") (fun oc -> - output_string oc "left behind") - in - plant dead; - plant (Unix.getpid ()); let pid = Unix.create_process flan [| flan; "dev"; "programs/dev-loop.flan"; "-s"; sock; "--llvm" |] @@ -270,15 +243,6 @@ let () = if not (listening ~pid sock) then fail "the daemon %s" !listen_why else begin - if Sys.file_exists (session_dir dead) then - fail "a dead session's directory %s outlived the next session's start" - (session_dir dead); - if not (Sys.file_exists (session_dir (Unix.getpid ()))) then - fail "a live process's session directory was swept"; - (try Sys.remove (Filename.concat (session_dir (Unix.getpid ())) "program") - with Sys_error _ -> ()); - (try Unix.rmdir (session_dir (Unix.getpid ())) - with Unix.Unix_error _ -> ()); (* The daemon owns the program's lifetime and kills it on [close], so every step waits for the program to have got there. "ok" from an eval means the module was queued, not that it has been installed. *) @@ -850,9 +814,6 @@ let () = transcript is the claim — after everything the first run printed and before anything the second did. *) ignore (Unix.waitpid [] pid); - if Sys.file_exists (session_dir pid) then - fail "a session that ended through close left %s behind" - (session_dir pid); let text = Buffer.contents output in let wanted = "1\n5\n105\n777\npk\n106\n" in if text <> wanted then