TXR's FFI can describe a C linked list and convert it in both directions in a call, with correct malloc/free memory management.

Given this code, which is compiled as part of a larger library called "crazyffi.so":

struct lnode {
char *datum;
struct lnode *next;
};

void list_update(struct lnode *list)
{
struct lnode *iter;
int i;

for (i = 0, iter = list; ; iter = iter->next, i++) {
char buf[256];
printf("lnode[%d]->datum = %s\n", i, iter->datum);

/* Edit every node by adding numeric prefix to the string.
* We free the old datum, and install a newly malloced
* string in its place.
*/
snprintf(buf, sizeof buf, "%d:%s", i, iter->datum);
free(iter->datum);
iter->datum = strdup(buf);

/* When visiting the last node, add one more node.
*/
if (!iter->next) {
snprintf(buf, sizeof buf, "%d:%s", i + 1, "cow!");
iter->next = malloc(sizeof *iter->next);
iter->next->datum = strdup(buf);
iter->next->next = 0;
break;
}
}
}

This TXR Lisp code calls the function:

(typedef lnode (struct lnode
(datum str)
(next (ptr (struct lnode)))))

(with-dyn-lib "./crazyffi.so"
(deffi list-update "list_update" void ((ptr lnode))))

(let ((ll #S(lnode datum "how"
next #S(lnode datum "now"
next #S(lnode datum "brown"
next nil)))))
(prinl ll)
(list-update ll)
(prinl ll))

Run it:

$ txr linked-test.tl
#S(lnode datum "how" next #S(lnode datum "now" next #S(lnode datum "brown" next nil)))
lnode[0]->datum = how
lnode[1]->datum = now
lnode[2]->datum = brown
#S(lnode datum "0:how" next #S(lnode datum "1:now" next #S(lnode datum "2:brown" next #S(lnode datum "3:cow!" next nil))))

We see that the list was altered with numeric prefixes on the strings, and a new node was added at the end.

Valgrind is completely clean:

$ valgrind txr linked-test.tl
==6707== Memcheck, a memory error detector
==6707== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
[ ... ]
#S(lnode datum "how" next #S(lnode datum "now" next #S(lnode datum "brown" next nil)))
lnode[0]->datum = how
lnode[1]->datum = now
lnode[2]->datum = brown
#S(lnode datum "0:how" next #S(lnode datum "1:now" next #S(lnode datum "2:brown" next #S(lnode datum "3:cow!" next nil))))
==6707==
==6707== HEAP SUMMARY:
==6707== in use at exit: 2,681,402 bytes in 9,346 blocks
==6707== total heap usage: 14,827 allocs, 5,481 frees, 3,034,573 bytes allocated
==6707==
==6707== LEAK SUMMARY:
==6707== definitely lost: 0 bytes in 0 blocks
==6707== indirectly lost: 0 bytes in 0 blocks
==6707== possibly lost: 1,286,596 bytes in 1,533 blocks
==6707== still reachable: 1,394,806 bytes in 7,813 blocks
==6707== suppressed: 0 bytes in 0 blocks
==6707== Rerun with --leak-check=full to see details of leaked memory
==6707==
==6707== For counts of detected and suppressed errors, rerun with: -v
==6707== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

Now with --free-all:

0:sun-go:~/txr$ valgrind txr --free-all linked-test.tl
==6721== Memcheck, a memory error detector
[ ... ]
#S(lnode datum "how" next #S(lnode datum "now" next #S(lnode datum "brown" next nil)))
lnode[0]->datum = how
lnode[1]->datum = now
lnode[2]->datum = brown
#S(lnode datum "0:how" next #S(lnode datum "1:now" next #S(lnode datum "2:brown" next #S(lnode datum "3:cow!" next nil))))
==6721==
==6721== HEAP SUMMARY:
==6721== in use at exit: 0 bytes in 0 blocks
==6721== total heap usage: 14,830 allocs, 14,830 frees, 3,034,665 bytes allocated
==6721==
==6721== All heap blocks were freed -- no leaks are possible
==6721==
==6721== For counts of detected and suppressed errors, rerun with: -v
==6721== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

What's missing is circularity detection; we cannot pass a graph back and forth in this manner. If we pass a DAG structure, its shared structure will get expanded.

Since circularity detection is expensive, that would have to be something enabled by some sort of attribute mechanism.