From 0f65e934be73eb878f130cf5e5f24359f33ff657 Mon Sep 17 00:00:00 2001 From: Martin Geisler Date: Sun, 10 May 2026 12:21:30 +0200 Subject: [PATCH 1/2] Implement bold-color support (bright and fixed) Add support for Ghostty's `bold-color` configuration, allowing bold text to be rendered using bright colors or a specific fixed color. Summary of changes: - src/terminal.zig: Add `BoldConfig` and `bold_config` field to `Terminal`. - src/module.zig: Add `ghostel--set-bold-config` to update bold settings. - src/render.zig: Implement color substitution logic in `readCellProps`. Bold text with palette colors 0-7 is mapped to 8-15. Bold text with default foreground uses `bold_config.fixed_color` if mode is `fixed`. - lisp/ghostel.el: Add `ghostel-bold-color` defcustom (default `bright`) and logic to apply it to new and existing terminals. - test/ghostel-test.el: Add unit tests for both 'bright and fixed-color bold modes. - src/emacs.zig: Add `bright` symbol to cached symbols. Matches Ghostty 1.2.0 behavior. > Written by Gemini, tested by me. --- lisp/ghostel.el | 45 +++++++++++++++++- src/Renderer.zig | 34 ++++++++++++-- src/emacs.zig | 1 + src/module.zig | 37 +++++++++++++++ test/ghostel-test.el | 107 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 220 insertions(+), 4 deletions(-) diff --git a/lisp/ghostel.el b/lisp/ghostel.el index acee652..1ce84ac 100644 --- a/lisp/ghostel.el +++ b/lisp/ghostel.el @@ -873,6 +873,7 @@ Used when `cursor-in-non-selected-windows' resolves to box.") (declare-function ghostel--mouse-event "ghostel-module") (declare-function ghostel--new "ghostel-module") (declare-function ghostel--redraw "ghostel-module" (term &optional full)) +(declare-function ghostel--set-bold-config "ghostel-module") (declare-function ghostel--set-default-colors "ghostel-module") (declare-function ghostel--set-palette "ghostel-module") (declare-function ghostel--set-size "ghostel-module" (term rows cols &optional cell-w cell-h)) @@ -1234,6 +1235,36 @@ Updated whenever the terminal is created or resized.") "Column count of the native terminal. Updated whenever the terminal is created or resized.") +(defcustom ghostel-bold-color nil + "Configure how bold text is colored. + +If nil (default), bold text uses the same color as normal text. + +If `bright', bold text uses the bright version of the current +foreground color (ANSI colors 0-7 map to 8-15). + +If a string (hex color like \"#RRGGBB\"), bold text with the default +foreground color uses this specific color. Bold text with a palette +color (0-7) will use the bright version (8-15). + +Matches Ghostty 1.2.0's `bold-color' configuration." + :type '(choice (const :tag "None" nil) + (const :tag "Bright" bright) + (string :tag "Fixed color (#RRGGBB)" + :match (lambda (_widget val) + (and (stringp val) + (string-match-p + "\\`#[0-9A-Fa-f]\\{6\\}\\'" val))))) + :group 'ghostel + :set (lambda (sym val) + (set-default sym val) + (dolist (buf (buffer-list)) + (with-current-buffer buf + (when (and (derived-mode-p 'ghostel-mode) ghostel--term) + (ghostel--apply-bold-config ghostel--term) + (let ((inhibit-read-only t)) + (ghostel--redraw ghostel--term t))))))) + (defvar-local ghostel--cursor-pos nil "The position of the terminal cursor as (COL . ROW) in terminal screen coords.") @@ -5023,6 +5054,15 @@ Falls back to \"#000000\" if the color cannot be resolved." ""))) (ghostel--set-palette term colors))))) +(defun ghostel--apply-bold-config (term) + "Apply `ghostel-bold-color' to terminal handle TERM." + (when (user-ptrp term) + (ghostel--load-module) + (ghostel--set-bold-config + term (if (eq ghostel-bold-color 'bright) + 'bright + ghostel-bold-color)))) + ;;; Theme synchronization @@ -5034,6 +5074,7 @@ Call this after changing the Emacs theme so terminals match." (with-current-buffer buf (when (and (derived-mode-p 'ghostel-mode) ghostel--term) (ghostel--apply-palette ghostel--term) + (ghostel--apply-bold-config ghostel--term) (when (ghostel--terminal-live-p) (setq ghostel--force-next-redraw t) (ghostel--delayed-redraw buf)))))) @@ -6363,7 +6404,8 @@ buffer can be found again after title-tracking renames it." ;; and the terminal advances the cursor zero rows, leaving the ;; next prompt on top of the image. (ghostel--set-size-with-cell-dims ghostel--term height width) - (ghostel--apply-palette ghostel--term)) + (ghostel--apply-palette ghostel--term) + (ghostel--apply-bold-config ghostel--term)) (ghostel--start-process)))) (defun ghostel--find-buffer-by-identity (identity) @@ -6441,6 +6483,7 @@ Signals `user-error' if BUFFER already has a live ghostel process." ;; see the matching call in `ghostel--ensure-buffer-state'. (ghostel--set-size-with-cell-dims ghostel--term height width) (ghostel--apply-palette ghostel--term) + (ghostel--apply-bold-config ghostel--term) (ghostel--spawn-pty program args height width ghostel--default-stty nil remote-p))))) diff --git a/src/Renderer.zig b/src/Renderer.zig index 5c78238..fd730d1 100644 --- a/src/Renderer.zig +++ b/src/Renderer.zig @@ -38,6 +38,15 @@ row: RowContent = .{}, font_info: ?FontInfo = null, +/// Bold text coloring configuration. +bold_config: BoldConfig = .none, + +pub const BoldConfig = union(enum) { + none, + bright, + fixed: gt.ColorRgb, +}; + const FontInfo = struct { width: i64, height: i64, @@ -285,7 +294,7 @@ fn formatColor(color: gt.ColorRgb, buf: *[7]u8) []const u8 { } /// Read the style for the current cell from the render state. -fn readCellProps(cells: gt.RenderStateRowCells, key: CellPropKey) !?CellProps { +fn readCellProps(self: *Self, term: *Terminal, cells: gt.RenderStateRowCells, key: CellPropKey) !?CellProps { var props: CellProps = .{}; props.fg = gt.rs_row_cells.get(gt.ColorRgb, cells, gt.RS_CELLS_DATA_FG_COLOR) catch |err| switch (err) { @@ -310,6 +319,22 @@ fn readCellProps(cells: gt.RenderStateRowCells, key: CellPropKey) !?CellProps { if (gs.underline_color.tag == gt.c.GHOSTTY_STYLE_COLOR_RGB) { props.underline_color = gs.underline_color.value.rgb; } + + // Bold color handling (matches Ghostty 1.2.0+) + if (props.bold and self.bold_config != .none) { + if (gs.fg_color.tag == gt.c.GHOSTTY_STYLE_COLOR_PALETTE) { + const index = gs.fg_color.value.palette; + if (index < 8) { + const palette = try term.getColorPalette(); + props.fg = palette[index + 8]; + } + } else if (gs.fg_color.tag == gt.c.GHOSTTY_STYLE_COLOR_NONE) { + switch (self.bold_config) { + .fixed => |fixed_color| props.fg = fixed_color, + else => {}, + } + } + } } props.hyperlink = key.hyperlink; @@ -500,6 +525,7 @@ pub const RowContent = struct { /// styled blanks are preserved. pub fn build( self: *RowContent, + term: *Terminal, row: gt.RenderStateRowIterator, row_cells: *gt.RenderStateRowCells, adjustment_threshold: u32, @@ -568,7 +594,7 @@ pub const RowContent = struct { try self.runs.append(RowContent.allocator, .{ .start_char = self.char_len, .end_char = self.char_len, - .props = try readCellProps(row_cells.*, prop_key), + .props = try readCellProps(&term.renderer, term, row_cells.*, prop_key), }); current_prop_key = prop_key; } @@ -716,9 +742,11 @@ fn adjustGlyphs(self: *Self, env: emacs.Env, row_start: i64) void { fn insertRow( self: *Self, env: emacs.Env, + term: *Terminal, default_colors: *const BgFg, ) !void { try self.row.build( + term, self.row_iterator, &self.row_cells, if (self.font_info) |f| f.coverage else std.math.maxInt(u32), @@ -853,7 +881,7 @@ pub fn render(self: *Self, env: emacs.Env, term: *Terminal, skip: usize, force_f const dirty_row = dirty_full or try gt.rs_row.get(bool, self.row_iterator, gt.RS_ROW_DATA_DIRTY); if (dirty_row) { env.deleteRegion(env.point(), env.lineBeginningPosition2()); - try self.insertRow(env, &default_colors); + try self.insertRow(env, term, &default_colors); } else { _ = env.forwardLine(1); } diff --git a/src/emacs.zig b/src/emacs.zig index cc25e43..b7a6261 100644 --- a/src/emacs.zig +++ b/src/emacs.zig @@ -391,6 +391,7 @@ const interned_symbols = [_][:0]const u8{ ":underline", ":weight", "bold", + "bright", "char-before", "cons", "dash", diff --git a/src/module.zig b/src/module.zig index 0939085..d2d6512 100644 --- a/src/module.zig +++ b/src/module.zig @@ -91,6 +91,13 @@ export fn emacs_module_init(runtime: *c.struct_emacs_runtime) callconv(.c) c_int \\ \\(ghostel--set-default-colors TERM FG-HEX BG-HEX) ); + env.bindFunction("ghostel--set-bold-config", 2, 2, &fnSetBoldConfig, + \\Configure bold text coloring. + \\ + \\CONFIG can be nil (none), 'bright, or a hex color string. + \\ + \\(ghostel--set-bold-config TERM CONFIG) + ); env.bindFunction("ghostel--mode-enabled", 2, 2, &fnModeEnabled, \\Return t if terminal DEC private MODE is enabled. \\ @@ -1088,6 +1095,36 @@ fn fnSetDefaultColors(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, return env.t(); } +/// (ghostel--set-bold-config TERM CONFIG) +/// +/// CONFIG can be nil (none), 'bright, or a hex color string. +fn fnSetBoldConfig(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?*anyopaque) callconv(.c) c.emacs_value { + const env = emacs.Env.init(raw_env.?); + const term = env.getUserPtr(Terminal, args[0]) orelse return env.nil(); + const val = args[1]; + + if (env.isNil(val)) { + term.renderer.bold_config = .none; + } else if (env.eq(val, emacs.sym.bright)) { + term.renderer.bold_config = .bright; + } else { + var hex_buf: [16]u8 = undefined; + const hex = env.extractString(val, &hex_buf) orelse { + env.signalError("invalid bold config value", .{}); + return env.nil(); + }; + + if (parseHexColor(hex)) |color| { + term.renderer.bold_config = .{ .fixed = color }; + } else { + env.signalError("invalid bold color: %s", .{hex}); + return env.nil(); + } + } + + return env.t(); +} + /// (ghostel--debug-state TERM) /// Returns a string with render state debug info. /// diff --git a/test/ghostel-test.el b/test/ghostel-test.el index a1c0ef3..9638490 100644 --- a/test/ghostel-test.el +++ b/test/ghostel-test.el @@ -14720,6 +14720,113 @@ slip past the unit tests." `(and "^ghostel-test-" (not (member ,@ghostel-test--elisp-tests))))) +(defun ghostel-test--bold-color-palette () + "Return a 256-entry hex palette string with index 1 red and 9 green. +Used by bold-color tests so palette mapping is observable." + (concat "#000000" ;; 0 + "#ff0000" ;; 1 (red) + (apply #'concat (make-list 7 "#000000")) ;; 2..8 + "#00ff00" ;; 9 (bright red, distinguishable) + (apply #'concat (make-list 246 "#000000")))) + +(ert-deftest ghostel-test-bold-is-bright () + "Test that bold text uses bright colors when ghostel-bold-color is 'bright." + (let ((buf (generate-new-buffer " *ghostel-test-bold*")) + (ghostel-bold-color 'bright)) + (unwind-protect + (with-current-buffer buf + (let* ((term (ghostel--new 5 40 100)) + (inhibit-read-only t)) + (ghostel--set-palette term (ghostel-test--bold-color-palette)) + (ghostel--apply-bold-config term) + + ;; Write bold red text + (ghostel--write-input term "\e[1;31mBOLD\e[0m") + (ghostel--redraw term) + (goto-char (point-min)) + (let ((face (get-text-property (point) 'face))) + (should (equal "#00ff00" (plist-get face :foreground))) + (should (eq 'bold (plist-get face :weight)))))) + (kill-buffer buf)))) + +(ert-deftest ghostel-test-bold-fixed-color () + "Test that bold text uses a fixed color when ghostel-bold-color is a hex string." + (let ((buf (generate-new-buffer " *ghostel-test-bold-fixed*")) + (ghostel-bold-color "#abcdef")) + (unwind-protect + (with-current-buffer buf + (let* ((term (ghostel--new 5 40 100)) + (inhibit-read-only t)) + (ghostel--apply-bold-config term) + + ;; Write bold text without color + (ghostel--write-input term "\e[1mBOLD\e[0m") + (ghostel--redraw term) + (goto-char (point-min)) + (let ((face (get-text-property (point) 'face))) + (should (equal "#abcdef" (plist-get face :foreground))) + (should (eq 'bold (plist-get face :weight)))))) + (kill-buffer buf)))) + +(ert-deftest ghostel-test-bold-color-nil-leaves-fg-alone () + "Test that bold text keeps its original color when `ghostel-bold-color' is nil." + (let ((buf (generate-new-buffer " *ghostel-test-bold-nil*")) + (ghostel-bold-color nil)) + (unwind-protect + (with-current-buffer buf + (let* ((term (ghostel--new 5 40 100)) + (inhibit-read-only t)) + (ghostel--set-palette term (ghostel-test--bold-color-palette)) + (ghostel--apply-bold-config term) + ;; Bold red (palette 1) must stay red — no brightening to palette 9. + (ghostel--write-input term "\e[1;31mBOLD\e[0m") + (ghostel--redraw term) + (goto-char (point-min)) + (let ((face (get-text-property (point) 'face))) + (should (equal "#ff0000" (plist-get face :foreground))) + (should (eq 'bold (plist-get face :weight)))))) + (kill-buffer buf)))) + +(ert-deftest ghostel-test-bold-fixed-also-brightens-palette () + "Test that fixed-color bold still maps palette 0-7 to 8-15. +The fixed color only applies to default-fg cells; palette colors take +the bright variant just like in `bright' mode." + (let ((buf (generate-new-buffer " *ghostel-test-bold-fixed-palette*")) + (ghostel-bold-color "#abcdef")) + (unwind-protect + (with-current-buffer buf + (let* ((term (ghostel--new 5 40 100)) + (inhibit-read-only t)) + (ghostel--set-palette term (ghostel-test--bold-color-palette)) + (ghostel--apply-bold-config term) + ;; Bold red (palette 1) → bright red (palette 9 = #00ff00), + ;; NOT the fixed color #abcdef. + (ghostel--write-input term "\e[1;31mBOLD\e[0m") + (ghostel--redraw term) + (goto-char (point-min)) + (let ((face (get-text-property (point) 'face))) + (should (equal "#00ff00" (plist-get face :foreground)))))) + (kill-buffer buf)))) + +(ert-deftest ghostel-test-bold-leaves-bright-palette-alone () + "Test that bold on palette 8-15 is not re-mapped (no overflow into 16-23)." + (let ((buf (generate-new-buffer " *ghostel-test-bold-bright-palette*")) + (ghostel-bold-color 'bright)) + (unwind-protect + (with-current-buffer buf + (let* ((term (ghostel--new 5 40 100)) + (inhibit-read-only t)) + (ghostel--set-palette term (ghostel-test--bold-color-palette)) + (ghostel--apply-bold-config term) + ;; SGR 91 selects palette 9 directly; bold must not shift it further. + (ghostel--write-input term "\e[1;91mBOLD\e[0m") + (ghostel--redraw term) + (goto-char (point-min)) + (let ((face (get-text-property (point) 'face))) + (should (equal "#00ff00" (plist-get face :foreground))) + (should (eq 'bold (plist-get face :weight)))))) + (kill-buffer buf)))) + (defun ghostel-test-run () "Run all ghostel tests." (ert-run-tests-batch-and-exit "^ghostel-test-")) From 76eb36de130447d763305e8db87ec9be20e24513 Mon Sep 17 00:00:00 2001 From: Daniel Kraus Date: Tue, 12 May 2026 10:46:00 +0200 Subject: [PATCH 2/2] Detect stale native module before module-load via sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the elisp package is upgraded ahead of the native module, the old .so was mapped into Emacs first and only then checked for version compatibility. By that point a restart was the only way to load the new binary, so users hit two restarts per upgrade and the interactive prompt at M-x ghostel never fired because the early-out at the top of ghostel--load-module short-circuited once the module was loaded. build.zig now writes a ghostel-module.version sidecar next to the binary, and ghostel--download-module / ghostel--install-built-module- on-finish keep it in sync (the downloader resolves /releases/latest/ to the redirected tag). ghostel--load-module consults the sidecar before module-load and refuses to map a stale .so, so a fresh install can be loaded in this same process — no second restart. The interactive version check also runs unconditionally at the tail of ghostel--load-module, so existing installs without a sidecar still surface the install prompt at M-x ghostel instead of only warning. Fixes #256. --- .github/workflows/ci.yml | 4 +- build.zig | 10 ++ lisp/ghostel.el | 231 +++++++++++++++++++------ src/module.zig | 5 +- src/version.zig | 3 + test/ghostel-test.el | 356 +++++++++++++++++++++++++++++++++++++-- 6 files changed, 547 insertions(+), 62 deletions(-) create mode 100644 src/version.zig diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35c4316..ccf561e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -165,7 +165,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: ghostel-module-${{ matrix.platform }} - path: ghostel-module${{ matrix.suffix }} + path: | + ghostel-module${{ matrix.suffix }} + ghostel-module.version test-native: needs: build-native diff --git a/build.zig b/build.zig index 55a1397..0d9ae43 100644 --- a/build.zig +++ b/build.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const module_version = @import("src/version.zig").version; const vendored_emacs_module_dir = "vendor"; @@ -60,6 +61,15 @@ pub fn build(b: *std.Build) void { ); b.getInstallStep().dependOn(©_step.step); + // Sidecar version file sitting next to the binary. The elisp loader + // reads this before `module-load` to detect a stale module without + // mapping it into the process. Mirrors the path of the .so/.dylib + // produced above. + const version_wf = b.addWriteFiles(); + const version_file = version_wf.add("ghostel-module.version", module_version ++ "\n"); + const copy_version_step = b.addInstallFile(version_file, "../ghostel-module.version"); + b.getInstallStep().dependOn(©_version_step.step); + // ---------------------------------------------------------------- // `zig build test` — pure-Zig unit tests for the decoder helpers. // diff --git a/lisp/ghostel.el b/lisp/ghostel.el index 1ce84ac..0e7a203 100644 --- a/lisp/ghostel.el +++ b/lisp/ghostel.el @@ -925,10 +925,20 @@ When VERSION is nil, use the latest release download URL." (format "%s/latest/download/%s" ghostel-github-release-url asset-name))))) +(defun ghostel--release-version-from-url (url) + "Return the release version embedded in URL, or nil. +GitHub's `/releases/latest/download/' redirect resolves to +`/releases/download/v/'; extract the X.Y.Z segment." + (when (and url + (string-match "/releases/download/v\\([0-9]+\\.[0-9]+\\.[0-9]+\\)/" + url)) + (match-string 1 url))) + (defun ghostel--download-module (dir &optional version latest-release) "Download a pre-built module into DIR. When VERSION is non-nil, download that release tag. When LATEST-RELEASE is non-nil, use the latest release asset URL. +On success, also writes the sidecar version file alongside the module. Returns non-nil on success." (condition-case err (let* ((requested-version (unless latest-release @@ -939,9 +949,24 @@ Returns non-nil on success." (error "Refusing non-HTTPS download URL: %s" url)) (make-directory dir t) (let ((dest (expand-file-name - (concat "ghostel-module" module-file-suffix) dir))) + (concat "ghostel-module" module-file-suffix) dir)) + (sidecar (ghostel--module-sidecar-path dir))) + ;; Drop any pre-existing sidecar so that, if the download or the + ;; subsequent sidecar write fails partway, the loader sees `absent' + ;; rather than `stale' (refuse to map a fresh module). + (when (file-exists-p sidecar) + (delete-file sidecar)) (message "ghostel: downloading native module from %s..." url) - (when (ghostel--download-file url dest) + (when-let* ((final-url (ghostel--download-file url dest))) + ;; Resolve the version that was actually fetched. For an + ;; explicit version we trust the request; for `latest' we + ;; parse the URL the server redirected us to. If parsing + ;; fails we fall back to the minimum so the sidecar still + ;; reflects a safe lower bound. + (let ((resolved (or requested-version + (ghostel--release-version-from-url final-url) + ghostel--minimum-module-version))) + (ghostel--write-module-sidecar-version dir resolved)) (message "ghostel: native module downloaded successfully") t)))) (error @@ -951,7 +976,7 @@ Returns non-nil on success." (defun ghostel--compile-module (dest-dir) "Compile the native module from source and install it in DEST-DIR. The build runs in `ghostel--resource-root' (which holds build.zig); -on success the produced module is renamed into DEST-DIR." +on success the produced module and its sidecar are moved into DEST-DIR." (let* ((source-dir (ghostel--resource-root)) (default-directory source-dir)) (message "ghostel: compiling native module with zig build (this may take a moment)...") @@ -972,8 +997,10 @@ on success the produced module is renamed into DEST-DIR." ((equal built final) (message "ghostel: native module compiled successfully")) (t - (make-directory dest-dir t) - (rename-file built final t) + (ghostel--install-module-pair + built final + (expand-file-name "ghostel-module.version" source-dir) + (expand-file-name "ghostel-module.version" dest-dir)) (message "ghostel: native module compiled successfully")))))) (file-missing (display-warning 'ghostel @@ -1024,17 +1051,19 @@ Choice: " url) (?s nil)))) (defun ghostel--download-file (url dest) - "Download URL to DEST atomically. Return non-nil on success. -Writes to a sibling temp file in the same directory and renames it -into place once the download succeeds. Renaming swaps the directory -entry to a new inode, so any process (notably a running Emacs) that -has the previous DEST file mmap'd keeps a valid mapping to the old -file content. Writing to DEST directly would truncate the existing -inode and corrupt that mapping." + "Download URL to DEST atomically. Return the final URL on success, else nil. +The returned URL reflects any HTTP redirects followed during the +fetch, which lets callers resolve a `latest' alias to the actual +release tag. Writes to a sibling temp file in the same directory +and renames it into place once the download succeeds. Renaming +swaps the directory entry to a new inode, so any process (notably +a running Emacs) that has the previous DEST file mmap'd keeps a +valid mapping to the old file content. Writing to DEST directly +would truncate the existing inode and corrupt that mapping." (let* ((url-request-method "GET") (url-show-status nil) (tmp (make-temp-name (concat dest ".tmp."))) - (ok nil)) + (final-url nil)) (unwind-protect (let ((buf (url-retrieve-synchronously url t t 30))) (when buf @@ -1050,13 +1079,20 @@ inode and corrupt that mapping." (write-region start (point-max) tmp nil 'silent) (set-file-modes tmp #o755) (rename-file tmp dest t) - (setq ok t)))))) + ;; `url-current-object' tracks the URL of the + ;; final response after redirect-following, so + ;; latest-asset URLs resolve to /download/vX.Y.Z/. + (setq final-url + (or (and (boundp 'url-current-object) + url-current-object + (url-recreate-url url-current-object)) + url))))))) (when (buffer-live-p buf) (kill-buffer buf))))) - (unless ok + (unless final-url (when (file-exists-p tmp) (ignore-errors (delete-file tmp))))) - ok)) + final-url)) (defun ghostel--package-directory () "Return the directory ghostel is loaded from, or nil." @@ -1090,6 +1126,52 @@ the shipped resource root." (ghostel--resource-root)))) (file-name-as-directory (expand-file-name dir)))) +(defun ghostel--module-sidecar-path (dir) + "Return the path of the module version sidecar inside DIR. +The sidecar is a one-line file holding the version string of the +neighbouring native module. It is written by `build.zig' at compile +time and by `ghostel--download-module' after a successful download, +so the loader can check the on-disk version without `module-load'." + (expand-file-name "ghostel-module.version" dir)) + +(defun ghostel--read-module-sidecar-version (dir) + "Return the version string recorded in DIR's sidecar, or nil. +A missing or empty file returns nil; the result is otherwise trimmed." + (let ((path (ghostel--module-sidecar-path dir))) + (when (file-readable-p path) + (let ((s (with-temp-buffer + (insert-file-contents path) + (string-trim (buffer-string))))) + (and (not (string-empty-p s)) s))))) + +(defun ghostel--write-module-sidecar-version (dir version) + "Write VERSION to DIR's sidecar atomically." + (make-directory dir t) + (let* ((dest (ghostel--module-sidecar-path dir)) + (tmp (make-temp-name (concat dest ".tmp.")))) + (unwind-protect + (let ((coding-system-for-write 'utf-8-unix)) + (write-region (concat version "\n") nil tmp nil 'silent) + (rename-file tmp dest t) + (setq tmp nil)) + (when (and tmp (file-exists-p tmp)) + (ignore-errors (delete-file tmp)))))) + +(defun ghostel--install-module-pair (built-mod final-mod + built-sidecar final-sidecar) + "Move BUILT-MOD and BUILT-SIDECAR into FINAL-MOD and FINAL-SIDECAR. +The destination sidecar is removed before the module is moved, so every +failure path ends in `sidecar absent' rather than `sidecar stale'. +The loader treats the former as backward-compat (load and run a live +version check); the latter as `refuse to map', which would leave the +user stuck with a fresh module they cannot load." + (make-directory (file-name-directory final-mod) t) + (when (file-exists-p final-sidecar) + (delete-file final-sidecar)) + (rename-file built-mod final-mod t) + (when (file-exists-p built-sidecar) + (rename-file built-sidecar final-sidecar t))) + (defun ghostel-download-module (&optional prompt-for-version) "Interactively download the pre-built native module for this platform. With PROMPT-FOR-VERSION, prompt for a release tag to download. @@ -1115,8 +1197,11 @@ Leaving the prompt empty downloads the latest release." "Move the built module from SOURCE-DIR into DEST-DIR when COMPILE-BUF finishes. Registers a one-shot `compilation-finish-functions' handler that filters on COMPILE-BUF and removes itself on first match. Used so the -interactive `ghostel-module-compile' honours `ghostel-module-directory'." +interactive `ghostel-module-compile' honours `ghostel-module-directory'. +The sidecar version file written by `build.zig' is moved alongside the +module." (let* ((file-name (concat "ghostel-module" module-file-suffix)) + (sidecar "ghostel-module.version") handler) (setq handler (lambda (buf status) @@ -1124,18 +1209,20 @@ interactive `ghostel-module-compile' honours `ghostel-module-directory'." (remove-hook 'compilation-finish-functions handler) (when (string-match-p "finished" status) (let ((built (expand-file-name file-name source-dir)) - (final (expand-file-name file-name dest-dir))) + (final (expand-file-name file-name dest-dir)) + (built-sidecar (expand-file-name sidecar source-dir)) + (final-sidecar (expand-file-name sidecar dest-dir))) (when (file-exists-p built) (condition-case err (progn - (make-directory dest-dir t) - (rename-file built final t) + (ghostel--install-module-pair + built final built-sidecar final-sidecar) (message "ghostel: module installed at %s" final)) (error (display-warning 'ghostel - (format "Build succeeded but moving %s to %s failed: %s" - built final (error-message-string err))))))))))) + (format "Build succeeded but installing into %s failed: %s" + dest-dir (error-message-string err))))))))))) (add-hook 'compilation-finish-functions handler))) (defun ghostel-module-compile () @@ -1175,31 +1262,81 @@ triggers an interactive prompt." (defun ghostel--load-module (&optional prompt-user) "Ensure the ghostel native module is loaded. When PROMPT-USER is non-nil (called from an interactive command like -`ghostel'), missing modules trigger `ghostel-module-auto-install' and -load failures signal `user-error' so the calling flow aborts. -Otherwise (load time, including byte-compilation and Emacs 31's -`user-lisp/' auto-compile), this function never prompts, downloads, -or compiles - it only loads an existing module file and warns if one -is missing. Module installation only happens on an explicit user -action: `M-x ghostel', `M-x ghostel-download-module', or -`M-x ghostel-module-compile'. +`ghostel'), missing or stale modules trigger +`ghostel-module-auto-install' and load failures signal `user-error' +so the calling flow aborts. Otherwise (load time, including +byte-compilation and Emacs 31's `user-lisp/' auto-compile), this +function never prompts, downloads, or compiles - it only loads an +existing module file and warns if one is missing or stale. Module +installation only happens on an explicit user action: `M-x ghostel', +`M-x ghostel-download-module', or `M-x ghostel-module-compile'. + +Before calling `module-load' the sidecar file +`ghostel-module.version' (written by `build.zig' and the downloader) +is consulted. When the sidecar reports a version older than +`ghostel--minimum-module-version' the .so is NOT mapped into this +process — that lets the freshly installed module be loaded in +place after a subsequent `ghostel--ensure-module' call, avoiding an +extra restart. The guard also honours `ghostel--new' being already `fboundp', which covers the pure-Elisp test path where `cl-letf' stubs the native entry points so tests run without the module present." - (unless (or (featurep 'ghostel-module) - (fboundp 'ghostel--new)) - (let* ((dir (ghostel--module-directory)) - (mod (expand-file-name - (concat "ghostel-module" module-file-suffix) dir))) - (when (and prompt-user (not (file-exists-p mod))) - (ghostel--ensure-module dir)) + (let* ((dir (ghostel--module-directory)) + (mod (expand-file-name + (concat "ghostel-module" module-file-suffix) dir)) + (sidecar-ver (ghostel--read-module-sidecar-version dir))) + (unless (or (featurep 'ghostel-module) + (fboundp 'ghostel--new)) (cond - ((file-exists-p mod) + ;; Sidecar tells us the on-disk module is too old. Refuse to map + ;; it so a subsequent install can `module-load' the fresh file in + ;; this same Emacs process. + ((and sidecar-ver + (version< sidecar-ver ghostel--minimum-module-version)) + (display-warning + 'ghostel + (format "Module version %s on disk is older than required %s" + sidecar-ver ghostel--minimum-module-version)) + (when prompt-user + (ghostel--ensure-module dir) + (let ((new-ver (ghostel--read-module-sidecar-version dir))) + (when (and (file-exists-p mod) + new-ver + (not (version< new-ver + ghostel--minimum-module-version))) + (condition-case err + (module-load mod) + (error + (user-error "Failed to load ghostel native module: %s" + (error-message-string err)))))))) + ;; Module file missing. + ((not (file-exists-p mod)) + (cond + (prompt-user + (ghostel--ensure-module dir) + (when (file-exists-p mod) + (condition-case err + (module-load mod) + (error + (user-error "Failed to load ghostel native module: %s" + (error-message-string err)))))) + (t + (display-warning + 'ghostel + (concat "Native module not found: " mod + "\nRun M-x ghostel-download-module or M-x ghostel-module-compile"))))) + ;; File exists and the sidecar (when present) is fresh. Load it. + ;; When the sidecar is absent (existing installs predating it), + ;; fall back to a live version check post-load. We skip the live + ;; check here when PROMPT-USER is non-nil; the tail below runs it + ;; instead, avoiding a double prompt. + (t (condition-case err (progn (module-load mod) - (ghostel--check-module-version dir prompt-user)) + (unless prompt-user + (ghostel--check-module-version dir nil))) (error (if prompt-user (user-error "Failed to load ghostel native module: %s" @@ -1207,15 +1344,13 @@ entry points so tests run without the module present." (display-warning 'ghostel (format "Failed to load native module: %s\nTry M-x ghostel-module-compile to rebuild" - (error-message-string err))))))) - (prompt-user - (user-error "Ghostel native module not found: %s. Run M-x ghostel-download-module or M-x ghostel-module-compile" - mod)) - (t - (display-warning - 'ghostel - (concat "Native module not found: " mod - "\nRun M-x ghostel-download-module or M-x ghostel-module-compile"))))))) + (error-message-string err))))))))) + ;; Surface the version check at every interactive entry point — not + ;; just when this call loaded the module. Otherwise a stale .so + ;; mapped in by an earlier load (e.g. sidecar absent at startup) is + ;; silently kept and the user only ever sees the bare warning. + (when (and prompt-user (featurep 'ghostel-module)) + (ghostel--check-module-version dir t)))) ;; Load the native module now so the rest of this file (declare-function, ;; feature consumers) sees it. Failure is non-fatal at load time. diff --git a/src/module.zig b/src/module.zig index d2d6512..150d47b 100644 --- a/src/module.zig +++ b/src/module.zig @@ -14,8 +14,9 @@ const pty = @import("pty.zig"); const c = emacs.c; -/// Module version — keep in sync with ghostel.el and build.zig.zon. -const version = "0.25.0"; +/// Module version — see src/version.zig. Keep in sync with ghostel.el +/// and build.zig.zon. +const version = @import("version.zig").version; // --------------------------------------------------------------------------- // Module entry point diff --git a/src/version.zig b/src/version.zig new file mode 100644 index 0000000..28b9b77 --- /dev/null +++ b/src/version.zig @@ -0,0 +1,3 @@ +/// Module version — single source of truth for src/module.zig and build.zig. +/// Keep in sync with `version` in build.zig.zon and `Version:` in lisp/ghostel.el. +pub const version = "0.25.0"; diff --git a/test/ghostel-test.el b/test/ghostel-test.el index 9638490..5289634 100644 --- a/test/ghostel-test.el +++ b/test/ghostel-test.el @@ -8798,9 +8798,14 @@ temp-file write under that directory errored out." (should-not warnings))))) (ert-deftest ghostel-test-compile-module-moves-to-dest-dir () - "Compilation moves the produced module into DEST-DIR when it differs." + "Compilation moves the produced module and sidecar into DEST-DIR. +The sync `ghostel--compile-module' path goes through +`ghostel--install-module-pair', which pre-deletes any stale dest +sidecar so a partially-completed install never leaves a fresh +module beside a stale sidecar (issue #256 follow-up B1)." (let ((rename-args nil) (made-dirs nil) + (deleted nil) (warnings nil)) (let ((native-comp-enable-subr-trampolines nil)) (cl-letf (((symbol-function 'ghostel--resource-root) @@ -8809,6 +8814,8 @@ temp-file write under that directory errored out." (lambda (_) t)) ((symbol-function 'make-directory) (lambda (dir &rest _) (push dir made-dirs))) + ((symbol-function 'delete-file) + (lambda (path) (push path deleted))) ((symbol-function 'rename-file) (lambda (from to &optional _ok) (push (list from to) rename-args))) @@ -8819,16 +8826,30 @@ temp-file write under that directory errored out." (lambda (&rest _) 0))) (ghostel--compile-module "/custom/dir/") (should-not warnings) - (should (equal 1 (length rename-args))) - (let ((args (car rename-args))) + (should (equal 1 (length deleted))) + (should (equal (downcase (expand-file-name + "ghostel-module.version" + "/custom/dir/")) + (downcase (car deleted)))) + (should (equal 2 (length rename-args))) + (let ((module-args (cadr rename-args)) + (sidecar-args (car rename-args))) (should (equal (downcase (expand-file-name (concat "ghostel-module" module-file-suffix) "/src/ghostel/")) - (downcase (nth 0 args)))) + (downcase (nth 0 module-args)))) (should (equal (downcase (expand-file-name (concat "ghostel-module" module-file-suffix) "/custom/dir/")) - (downcase (nth 1 args))))) + (downcase (nth 1 module-args)))) + (should (equal (downcase (expand-file-name + "ghostel-module.version" + "/src/ghostel/")) + (downcase (nth 0 sidecar-args)))) + (should (equal (downcase (expand-file-name + "ghostel-module.version" + "/custom/dir/")) + (downcase (nth 1 sidecar-args))))) (should (member "/custom/dir/" made-dirs)))))) (ert-deftest ghostel-test-compile-module-warns-when-build-missing () @@ -8868,11 +8889,13 @@ temp-file write under that directory errored out." (ert-deftest ghostel-test-module-compile-installs-when-dest-differs () "Interactive compile installs the built module into `ghostel-module-directory'. -A `compilation-finish-functions' handler renames the artifact when -the dest directory differs from the source root." +A `compilation-finish-functions' handler runs +`ghostel--install-module-pair', which pre-deletes any existing dest +sidecar and then renames the module and sidecar into place." (let* ((compile-buf (generate-new-buffer " *ghostel-test-compile*")) (compilation-finish-functions nil) (rename-args nil) + (deleted nil) (made-dirs nil) (default-directory nil) (ghostel-module-directory "/custom/dir/")) @@ -8886,6 +8909,8 @@ the dest directory differs from the source root." (lambda (_) t)) ((symbol-function 'make-directory) (lambda (dir &rest _) (push dir made-dirs))) + ((symbol-function 'delete-file) + (lambda (path) (push path deleted))) ((symbol-function 'rename-file) (lambda (from to &optional _ok) (push (list from to) rename-args))) @@ -8895,16 +8920,32 @@ the dest directory differs from the source root." ;; Simulate compilation completion. (funcall (car compilation-finish-functions) compile-buf "finished\n") (should (null compilation-finish-functions)) - (should (equal 1 (length rename-args))) - (let ((args (car rename-args))) + ;; Pre-existing dest sidecar must be removed before any rename. + (should (equal 1 (length deleted))) + (should (equal (downcase (expand-file-name + "ghostel-module.version" + "/custom/dir/")) + (downcase (car deleted)))) + (should (equal 2 (length rename-args))) + ;; rename-args is push-order (newest first): sidecar, then module. + (let ((module-args (cadr rename-args)) + (sidecar-args (car rename-args))) (should (equal (downcase (expand-file-name (concat "ghostel-module" module-file-suffix) "/src/ghostel/")) - (downcase (nth 0 args)))) + (downcase (nth 0 module-args)))) (should (equal (downcase (expand-file-name (concat "ghostel-module" module-file-suffix) "/custom/dir/")) - (downcase (nth 1 args))))))) + (downcase (nth 1 module-args)))) + (should (equal (downcase (expand-file-name + "ghostel-module.version" + "/src/ghostel/")) + (downcase (nth 0 sidecar-args)))) + (should (equal (downcase (expand-file-name + "ghostel-module.version" + "/custom/dir/")) + (downcase (nth 1 sidecar-args))))))) (when (buffer-live-p compile-buf) (kill-buffer compile-buf))))) @@ -9014,6 +9055,287 @@ missing-file code path, then restores them." (ghostel--check-module-version "/tmp") (should-not warned)))) +;; ----------------------------------------------------------------------- +;; Test: sidecar version file round-trip and pre-load gating +;; ----------------------------------------------------------------------- + +(ert-deftest ghostel-test-sidecar-read-missing () + "Reading a missing sidecar returns nil." + (let ((tmp (make-temp-file "ghostel-test-sidecar" t))) + (unwind-protect + (should (null (ghostel--read-module-sidecar-version tmp))) + (delete-directory tmp t)))) + +(ert-deftest ghostel-test-sidecar-read-empty () + "Reading an empty sidecar returns nil." + (let ((tmp (make-temp-file "ghostel-test-sidecar" t))) + (unwind-protect + (progn + (with-temp-file (ghostel--module-sidecar-path tmp)) + (should (null (ghostel--read-module-sidecar-version tmp)))) + (delete-directory tmp t)))) + +(ert-deftest ghostel-test-sidecar-round-trip () + "Writing then reading the sidecar returns the same version string." + (let ((tmp (make-temp-file "ghostel-test-sidecar" t))) + (unwind-protect + (progn + (ghostel--write-module-sidecar-version tmp "1.2.3") + (should (equal "1.2.3" (ghostel--read-module-sidecar-version tmp)))) + (delete-directory tmp t)))) + +(ert-deftest ghostel-test-sidecar-trims-whitespace () + "Trailing whitespace in the sidecar is trimmed by the reader." + (let ((tmp (make-temp-file "ghostel-test-sidecar" t))) + (unwind-protect + (progn + (with-temp-file (ghostel--module-sidecar-path tmp) + (insert " 0.9.1 \n\n")) + (should (equal "0.9.1" (ghostel--read-module-sidecar-version tmp)))) + (delete-directory tmp t)))) + +(ert-deftest ghostel-test-release-version-from-url () + "Parsing a /releases/download/vX.Y.Z/asset URL extracts the version." + (should (equal + "0.25.0" + (ghostel--release-version-from-url + "https://github.com/owner/repo/releases/download/v0.25.0/ghostel-module-x86_64-linux.so"))) + ;; latest-style URL (server hasn't redirected) — no version embedded. + (should (null + (ghostel--release-version-from-url + "https://github.com/owner/repo/releases/latest/download/ghostel-module-x86_64-linux.so"))) + (should (null (ghostel--release-version-from-url nil)))) + +(ert-deftest ghostel-test-load-module-skips-stale-sidecar-at-load-time () + "When the sidecar reports a stale version, `module-load' must NOT run. +At load time PROMPT-USER is nil so we only warn and skip — no +prompt, no install, no `module-load' of the stale binary." + (let* ((tmp (make-temp-file "ghostel-test-load-stale" t)) + (mod (expand-file-name (concat "ghostel-module" module-file-suffix) + tmp)) + (ghostel--minimum-module-version "0.25.0") + (load-calls nil) + (ensure-calls nil) + (warned nil) + (had-feat (featurep 'ghostel-module)) + (saved-new (and (fboundp 'ghostel--new) + (symbol-function 'ghostel--new)))) + (unwind-protect + (progn + (when had-feat + (setq features (delq 'ghostel-module features))) + (when saved-new + (fmakunbound 'ghostel--new)) + (with-temp-file mod (insert "stub")) + (ghostel--write-module-sidecar-version tmp "0.20.0") + (cl-letf (((symbol-function 'ghostel--module-directory) + (lambda () (file-name-as-directory tmp))) + ((symbol-function 'module-load) + (lambda (path) (push path load-calls))) + ((symbol-function 'ghostel--ensure-module) + (lambda (dir) (push dir ensure-calls))) + ((symbol-function 'display-warning) + (lambda (&rest _) (setq warned t)))) + (ghostel--load-module) + (should warned) + (should (null load-calls)) + (should (null ensure-calls)))) + (delete-directory tmp t) + (when saved-new + (fset 'ghostel--new saved-new)) + (when had-feat + (cl-pushnew 'ghostel-module features))))) + +(ert-deftest ghostel-test-load-module-prompts-on-stale-sidecar () + "Interactive entry with a stale sidecar runs `ghostel--ensure-module'. +After install refreshes the sidecar, the fresh module is loaded +in-process so no Emacs restart is needed." + (let* ((tmp (make-temp-file "ghostel-test-load-stale-i" t)) + (mod (expand-file-name (concat "ghostel-module" module-file-suffix) + tmp)) + (ghostel--minimum-module-version "0.25.0") + (load-calls nil) + (ensure-calls nil) + (had-feat (featurep 'ghostel-module)) + (saved-new (and (fboundp 'ghostel--new) + (symbol-function 'ghostel--new)))) + (unwind-protect + (progn + (when had-feat + (setq features (delq 'ghostel-module features))) + (when saved-new + (fmakunbound 'ghostel--new)) + (with-temp-file mod (insert "stub")) + (ghostel--write-module-sidecar-version tmp "0.20.0") + (cl-letf (((symbol-function 'ghostel--module-directory) + (lambda () (file-name-as-directory tmp))) + ((symbol-function 'module-load) + (lambda (path) (push path load-calls))) + ;; Simulate a successful install: rewrite the sidecar + ;; to the current minimum, leaving the .so in place. + ((symbol-function 'ghostel--ensure-module) + (lambda (dir) + (push dir ensure-calls) + (ghostel--write-module-sidecar-version + dir ghostel--minimum-module-version))) + ((symbol-function 'display-warning) + (lambda (&rest _) nil))) + (ghostel--load-module t) + (should (equal 1 (length ensure-calls))) + (should (equal (list mod) load-calls)))) + (delete-directory tmp t) + (when saved-new + (fset 'ghostel--new saved-new)) + (when had-feat + (cl-pushnew 'ghostel-module features))))) + +(ert-deftest ghostel-test-load-module-prompts-when-loaded-but-stale () + "Stale already-loaded module triggers a prompt at interactive entry. +Issue #256: when no sidecar existed at startup the elisp loader +still mapped the stale .so, and the previous version check ran +with PROMPT-USER nil so only a bare warning was surfaced. After +the fix `M-x ghostel' must offer the install dialog." + (let* ((tmp (make-temp-file "ghostel-test-loaded-stale" t)) + (ghostel--minimum-module-version "0.25.0") + (warned nil) + (ensure-calls nil) + (had-feat (featurep 'ghostel-module))) + (unwind-protect + (progn + ;; Pretend the (stale) module is already loaded. + (cl-pushnew 'ghostel-module features) + (cl-letf (((symbol-function 'ghostel--module-directory) + (lambda () (file-name-as-directory tmp))) + ((symbol-function 'ghostel--module-version) + (lambda () "0.20.0")) + ((symbol-function 'ghostel--ensure-module) + (lambda (dir) (push dir ensure-calls))) + ((symbol-function 'display-warning) + (lambda (&rest _) (setq warned t)))) + (ghostel--load-module t) + (should warned) + (should (equal 1 (length ensure-calls))))) + (delete-directory tmp t) + (unless had-feat + (setq features (delq 'ghostel-module features)))))) + +(ert-deftest ghostel-test-load-module-no-prompt-on-loaded-stale-at-load-time () + "Even with a stale loaded module, the load-time call must NOT prompt. +The interactive prompt is gated on PROMPT-USER; load-time +auto-execution (e.g. byte-compile) only warns." + (let* ((tmp (make-temp-file "ghostel-test-loaded-stale-load" t)) + (ghostel--minimum-module-version "0.25.0") + (ensure-calls nil) + (had-feat (featurep 'ghostel-module))) + (unwind-protect + (progn + (cl-pushnew 'ghostel-module features) + (cl-letf (((symbol-function 'ghostel--module-directory) + (lambda () (file-name-as-directory tmp))) + ((symbol-function 'ghostel--module-version) + (lambda () "0.20.0")) + ((symbol-function 'ghostel--ensure-module) + (lambda (dir) (push dir ensure-calls))) + ((symbol-function 'display-warning) + (lambda (&rest _) nil))) + (ghostel--load-module) + (should (null ensure-calls)))) + (delete-directory tmp t) + (unless had-feat + (setq features (delq 'ghostel-module features)))))) + +(ert-deftest ghostel-test-install-module-pair-deletes-stale-sidecar () + "Existing dest sidecar is removed before the new module is moved. +`ghostel--install-module-pair' keeps the invariant that a fresh +module is never paired with a stale sidecar." + (let* ((src (make-temp-file "ghostel-test-pair-src" t)) + (dst (make-temp-file "ghostel-test-pair-dst" t)) + (built-mod (expand-file-name "ghostel-module.so" src)) + (final-mod (expand-file-name "ghostel-module.so" dst)) + (built-sidecar (expand-file-name "ghostel-module.version" src)) + (final-sidecar (expand-file-name "ghostel-module.version" dst))) + (unwind-protect + (progn + (with-temp-file built-mod (insert "new-so")) + (with-temp-file built-sidecar (insert "0.99.0\n")) + (with-temp-file final-sidecar (insert "0.10.0\n")) + (ghostel--install-module-pair built-mod final-mod + built-sidecar final-sidecar) + (should (file-exists-p final-mod)) + (should (equal "new-so" (with-temp-buffer + (insert-file-contents final-mod) + (buffer-string)))) + (should (equal "0.99.0" (ghostel--read-module-sidecar-version dst))) + (should-not (file-exists-p built-mod)) + (should-not (file-exists-p built-sidecar))) + (delete-directory src t) + (delete-directory dst t)))) + +(ert-deftest ghostel-test-install-module-pair-sidecar-failure-leaves-absent () + "A sidecar rename failure leaves the dest in `absent' state. +The pre-delete in `ghostel--install-module-pair' guarantees that if +the second rename fails, the destination has a fresh module but +NO sidecar — which the loader treats as backward-compat live check, +not as `refuse to map'." + (let* ((src (make-temp-file "ghostel-test-pair-fail-src" t)) + (dst (make-temp-file "ghostel-test-pair-fail-dst" t)) + (built-mod (expand-file-name "ghostel-module.so" src)) + (final-mod (expand-file-name "ghostel-module.so" dst)) + (built-sidecar (expand-file-name "ghostel-module.version" src)) + (final-sidecar (expand-file-name "ghostel-module.version" dst)) + (real-rename (symbol-function 'rename-file))) + (unwind-protect + (progn + (with-temp-file built-mod (insert "new-so")) + (with-temp-file built-sidecar (insert "0.99.0\n")) + (with-temp-file final-sidecar (insert "0.10.0\n")) + (cl-letf (((symbol-function 'rename-file) + (lambda (from to &optional ok) + (if (string-suffix-p ".version" from) + (signal 'file-error '("simulated sidecar rename failure")) + (funcall real-rename from to ok))))) + (should-error + (ghostel--install-module-pair built-mod final-mod + built-sidecar final-sidecar) + :type 'file-error)) + ;; Fresh module landed. + (should (file-exists-p final-mod)) + (should (equal "new-so" (with-temp-buffer + (insert-file-contents final-mod) + (buffer-string)))) + ;; Stale sidecar was pre-deleted, new sidecar rename failed — + ;; net result is `absent', not `stale'. + (should-not (file-exists-p final-sidecar))) + (delete-directory src t) + (delete-directory dst t)))) + +(ert-deftest ghostel-test-download-module-deletes-stale-sidecar-on-failure () + "A failed download leaves no stale sidecar behind. +The download path pre-deletes the dest sidecar so that even if +`ghostel--download-file' fails, the loader falls back to the live +version check on whatever module is on disk instead of refusing +to map it based on stale sidecar metadata." + (let* ((dir (make-temp-file "ghostel-test-dl-fail" t)) + (sidecar (ghostel--module-sidecar-path dir))) + (unwind-protect + (progn + (with-temp-file sidecar (insert "0.10.0\n")) + (cl-letf (((symbol-function 'ghostel--download-file) + (lambda (&rest _) nil)) + ((symbol-function 'message) (lambda (&rest _)))) + (should-not (ghostel--download-module dir))) + (should-not (file-exists-p sidecar))) + (delete-directory dir t)))) + +(ert-deftest ghostel-test-build-emits-sidecar-version () + "`zig build' writes a sidecar matching the live module version. +Runs in the native test suite so the build has already produced +both the .so/.dylib and ghostel-module.version next to it." + (let* ((dir (ghostel--module-directory)) + (sidecar (ghostel--read-module-sidecar-version dir))) + (should (stringp sidecar)) + (should (equal sidecar (ghostel--module-version))))) + ;; ----------------------------------------------------------------------- ;; Test: platform tag arch normalization ;; ----------------------------------------------------------------------- @@ -14453,6 +14775,18 @@ slip past the unit tests." ghostel-test-module-version-mismatch ghostel-test-module-version-newer-than-minimum ghostel-test-load-module-no-prompt-at-load-time + ghostel-test-sidecar-read-missing + ghostel-test-sidecar-read-empty + ghostel-test-sidecar-round-trip + ghostel-test-sidecar-trims-whitespace + ghostel-test-release-version-from-url + ghostel-test-load-module-skips-stale-sidecar-at-load-time + ghostel-test-load-module-prompts-on-stale-sidecar + ghostel-test-load-module-prompts-when-loaded-but-stale + ghostel-test-load-module-no-prompt-on-loaded-stale-at-load-time + ghostel-test-install-module-pair-deletes-stale-sidecar + ghostel-test-install-module-pair-sidecar-failure-leaves-absent + ghostel-test-download-module-deletes-stale-sidecar-on-failure ghostel-test-platform-tag-normalizes-arch ghostel-test-title-does-not-overwrite-manual-rename ghostel-test-title-tracking-disabled