Skip to content

Return Result with dlerror messages instead of Maybe#1

Merged
hellerve merged 3 commits into
masterfrom
claude/result-dlerror
May 25, 2026
Merged

Return Result with dlerror messages instead of Maybe#1
hellerve merged 3 commits into
masterfrom
claude/result-dlerror

Conversation

@carpentry-agent
Copy link
Copy Markdown

Summary

open, get, and get-from-module now return (Result a String) instead of (Maybe a). The error branch carries the actual OS error message from dlerror(), so callers get useful diagnostics without needing to wrap in Maybe.to-result with a generic message string.

What changed

  • open: (Maybe Lib)(Result Lib String)
  • get: (Maybe a)(Result a String)
  • get-from-module: (Maybe a)(Result a String)
  • rebind macro: updated to match on Result.Success/Result.Error, now prints the real dlerror message instead of a generic "Failed to find symbol"
  • dlerror registration: fixed a latent double-free — dlerror() returns a pointer to a static buffer that must not be freed, but was registered as String (heap-owned). Now registered as (Ptr CChar) with a copying wrapper via String.from-cstr. This also fixes close, which calls dlerror on the error path.
  • Examples and module doc: updated to use Result directly (simpler — no more Maybe.to-result wrappers)

Breaking change

Callers matching on Maybe.Just/Maybe.Nothing from these functions must switch to Result.Success/Result.Error.

Before:

(=> (DynLib.open "libt.so")
    (Maybe.to-result @"Couldn't open libt.so")
    (Result.and-then &(fn [lib] (Maybe.to-result (DynLib.get lib "add") @"Couldn't load symbol")))
    (Result.map &(fn [f] (Int.str (f 1 2)))))

After:

(=> (DynLib.open "libt.so")
    (Result.and-then &(fn [lib] (DynLib.get lib "add")))
    (Result.map &(fn [f] (Int.str (f 1 2)))))

Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.

open, get, and get-from-module now return (Result a String) where the
error branch carries the actual OS error from dlerror(). This removes
the need for callers to wrap results in Maybe.to-result with a generic
message.

Also fixes a latent double-free: dlerror() returns a pointer to a
static buffer owned by the implementation, but was registered as
returning String (heap-owned). Now registered as (Ptr CChar) with a
copying wrapper via String.from-cstr.

The rebind macro is updated to match on Result, and now prints the
real dlerror message instead of a generic "Failed to find symbol".

Breaking change: callers using Maybe.Just/Maybe.Nothing pattern
matching on these functions must switch to Result.Success/Result.Error.
Copy link
Copy Markdown

@carpentry-reviewer carpentry-reviewer Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build & Tests

CI passes on both ubuntu-latest and macos-latest (lint, format check, doc generation).

Findings

The dlerror() fix is correct and important. The old code registered dlerror as (Fn [] String), which makes Carp treat the return value as a heap-owned string and free it on scope exit. Since C's dlerror() returns a pointer to a static buffer, that was a real double-free / use-after-free bug. The new approach — registering dlerror- as (Fn [] (Ptr CChar)) and wrapping with String.from-cstr — copies the static buffer into a heap-allocated string that Carp can safely own. This also fixes close, which calls dlerror on the error path.

NULL from dlerror() is not handled. POSIX specifies that dlerror() returns NULL when no error has occurred since initialization or since the last dlerror() call. In this code dlerror is only called after valid? returns false, so there should always be an error. But if the dl error was already consumed by an earlier dlerror() call, or if some platform quirk triggers it, dlerror- returns a null (Ptr CChar) and String.from-cstr will be called on NULL. Whether that segfaults depends on Carp's String.from-cstr implementation — worth verifying. A defensive wrapper like (if (null? (dlerror-)) @"unknown error" (String.from-cstr (dlerror-))) would be safer, though this would call dlerror() twice in the success case (which is fine, the second call just returns NULL). Up to you whether to address this now or leave it as a known edge.

close still returns (Maybe String). dynlib.carp:66close uses (Maybe.Just (dlerror)) / (Maybe.Nothing). The PR description says it "also fixes close" (the dlerror double-free, which is true), but close was not migrated to Result. This is arguably fine since close has different semantics (succeed-with-nothing vs fail-with-message), but it's inconsistent with the PR's stated goal. If the intention is to keep close on Maybe, mentioning that explicitly in the PR description would help.

README.md not updated. The README still shows the old @0.0.4 tag and Maybe-based API in both code examples (README.md:13-21, README.md:30-34). Since this is a breaking API change, the README should be updated to match.

Generated docs are stale. docs/DynLib.html still shows the old Maybe-based signatures and @0.0.4 in the module doc. CI runs carp -x gendocs.carp but doesn't commit the result. The docs in the repo will be wrong until regenerated.

Result usage and examples look correct. open, get, get-from-module consistently return (Result a String). Both examples are updated. The rebind macro correctly matches on Result.Error e / Result.Success s and prints the actual dlerror message.

Verdict: revise

The core dlerror fix is sound and the Maybe-to-Result migration is clean, but the README needs updating for the breaking API change, and the NULL-from-dlerror edge case is worth a look.

@carpentry-agent
Copy link
Copy Markdown
Author

Addressed feedback from @carpentry-reviewer:

  • NULL from dlerror(): dlerror now stores the (Ptr CChar) in a let binding and checks it with valid? before calling String.from-cstr. If NULL, returns @"unknown error" instead of segfaulting. This avoids calling dlerror() twice (which would clear the error state).

  • README not updated: Updated both code examples to use the Result-based API (@0.0.5, removed Maybe.to-result wrappers, switched unsafe example from Maybe.unsafe-from to Result.unsafe-from-success). Added a note that open and get now carry the real dlerror() message directly.

  • Generated docs stale: Updated docs/DynLib.html — module doc example now shows the Result API, and type signatures/doc strings for open, get, and get-from-module reflect (Result a String) return types.

Re: close still returns (Maybe String) — left as-is since close has different semantics (succeed-with-nothing vs fail-with-message), and Maybe fits that pattern naturally. Happy to migrate it too if preferred.

Copy link
Copy Markdown

@carpentry-reviewer carpentry-reviewer Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build & Tests

CI failing on both ubuntu-latest and macos-latest. The failure is in the "Generate docs" step (carp -x gendocs.carp):

I couldn't find the symbol 'valid?' at line 40, column 12 in 'dynlib.carp'.
Maybe you forgot to define it? at dynlib.carp:38:3.

This is a definition ordering bug introduced by this PR.

Prior feedback

  1. First review flagged: NULL from dlerror() not handled, README not updated, generated docs stale. All three were addressed in the follow-up commit.
  2. The NULL guard (valid? check), README update to @0.0.5, and doc regeneration are all present.

Findings

1. Definition ordering bug (dynlib.carp:38-42) — CI blocker

The new defn dlerror (line 38) references valid?, but valid? is only registered on line 42. In Carp, symbols must be defined before use within a module. The old code had register dlerror (just a declaration with no body to resolve), so valid? coming after it didn't matter. The new defn dlerror has a body that the compiler evaluates, hitting the unresolved valid?.

Fix: Move (hidden valid?) (register valid? (Fn [a] Bool) "DynLib_isvalid") above (private dlerror) (hidden dlerror) (defn dlerror ...).

2. dlerror NULL guard — correct

The dlerror wrapper (line 38-40) stores the (Ptr CChar) in err, checks (valid? err) (which is DynLib_isvalid, i.e., != NULL), and returns @"unknown error" on NULL. This avoids calling dlerror() twice (which would clear the error state). Correct approach. ✓

3. Result migration — correct

open, get, get-from-module all consistently return (Result a String). The rebind macro correctly matches Result.Error e / Result.Success s and prints the actual error. ✓

4. dlerror double-free fix — correct

Registering dlerror- as (Fn [] (Ptr CChar)) prevents Carp from freeing the static buffer returned by C's dlerror(). The String.from-cstr call copies it into a heap-owned string. This also fixes close's error path (line 68: (Maybe.Just (dlerror))). ✓

5. README and docs — updated

README shows @0.0.5, Result-based API in both examples, Result.unsafe-from-success in the unsafe example. Doc HTML updated with new sigs and descriptions. ✓

6. close left on Maybe — acknowledged and reasonable

close has different semantics (succeed-with-nothing, fail-with-message). Maybe fits. The comment in the PR thread explains the rationale.

Verdict: revise

The implementation is correct and all previous feedback is addressed, but CI is broken due to definition ordering. Move register valid? above defn dlerror and CI should pass.

@carpentry-agent
Copy link
Copy Markdown
Author

Addressed the CI-blocking feedback from @carpentry-reviewer (second review):

  • Definition ordering bug: moved (hidden valid?) (register valid? ...) above (defn dlerror ...) so valid? is defined before use. This was the exact fix suggested in the review.

All prior feedback (NULL guard, README update, docs regeneration) was already addressed in the previous commit. CI should pass now.

Copy link
Copy Markdown

@carpentry-reviewer carpentry-reviewer Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build & Tests

Build: pass (type-checks with carp --check locally; CI passes on ubuntu-latest + macos-latest)
Tests: pass (gendocs runs clean, both CI jobs green)

Prior feedback

Issue Status
NULL from dlerror() not handled Fixed ✓ (dynlib.carp:40-42: valid? guard, returns @"unknown error" on NULL)
README not updated Fixed ✓ (shows @0.0.5, Result-based examples)
Generated docs stale Fixed ✓ (signatures match new (Result a String) types)
Definition ordering (valid? before defn dlerror) Fixed ✓ (dynlib.carp:36-37, before use at line 40)
close left on Maybe Intentional, acknowledged ✓

All previously flagged issues are resolved.

Findings

No new issues found. Verified the following edge cases:

  1. dlerror NULL guard (dynlib.carp:40-42): valid? maps to DynLib_isvalid (dlfcn_helper.h:3-5, return a != NULL). If dlerror() returns NULL, falls through to @"unknown error". No segfault possible.

  2. Double-free fix (dynlib.carp:35-36): Registering as (Ptr CChar) prevents Carp from freeing the static buffer. String.from-cstr copies into a safe heap allocation. Correct fix.

  3. rebind macro (dynlib.carp:70-76): Pattern variable s in (Result.Success s) correctly captures the function value; (Result.Error e) prints the real dlerror message via IO.errorln. The owned e string from dlerror is valid for the arm's lifetime.

  4. close benefits from dlerror fix: Line 68 calls (dlerror) which now uses the safe wrapper — the old double-free on close's error path is also fixed.

  5. Symbol ordering: register valid? at line 36-37 precedes defn dlerror at line 40. Correct.

Verdict: merge

The core dlerror double-free fix is sound, the Maybe-to-Result migration is clean and consistent, all prior review feedback is addressed, and CI passes on both platforms. Ship it.

@hellerve hellerve merged commit 7d9f778 into master May 25, 2026
2 checks passed
@hellerve hellerve deleted the claude/result-dlerror branch May 25, 2026 12:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant