Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 0 additions & 36 deletions .github/workflows/test-darwin-arm64.yaml

This file was deleted.

7 changes: 6 additions & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ jobs:
strategy:
matrix:
go: ['1.25', '1.26']
host: [ubuntu-latest, macos-15-intel, windows-latest, ubuntu-24.04-arm]
host:
- ubuntu-latest
- macos-15-intel
- windows-latest
- ubuntu-24.04-arm
- macos-latest

steps:
- name: Checkout code
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![Go Reference](https://pkg.go.dev/badge/github.com/pboyd/redefine.svg)](https://pkg.go.dev/github.com/pboyd/redefine)

Highly experimental package to redefine Go functions at runtime as some interpreted languages allow (Ruby, Perl, etc.). I wrote about how this works and some of the limitations [here](https://pboyd.io/posts/redefining-go-functions/). This is a fun experiment, but do not use it for production code.
Highly experimental package to redefine Go functions at runtime as some interpreted languages allow (Ruby, Perl, etc.). I wrote about how this works and some of the limitations [here](https://pboyd.io/posts/redefining-go-functions/), and about Darwin / Mac OS support in particular [here](https://pboyd.io/posts/redefining-go-functions-on-darwin-arm64/). This is a fun experiment, but do not use it for production code.

```go
package main
Expand Down Expand Up @@ -38,10 +38,10 @@ It's 5:00 PM somewhere
| Darwin (macOS) | amd64 | Full | |
| Linux | arm64 | Full | |
| Windows | arm64 | Full | |
| Darwin (macOS) | arm64 | Full | |
| FreeBSD | amd64 | Untested | Compiles but untested |
| OpenBSD | amd64 | Untested | Compiles but untested |
| NetBSD | amd64 | Untested | Compiles but untested |
| Darwin (macOS) | arm64 | Broken | `mprotect` returns EACCES |

## FAQ

Expand Down
33 changes: 14 additions & 19 deletions clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"unsafe"

"github.com/pboyd/malloc"
"github.com/pboyd/redefine/internal/cacheflush"
"github.com/pboyd/redefine/internal/static"
)

var errAddressOutOfRange = errors.New("address out of range")
Expand All @@ -23,7 +25,7 @@ func cloneFunc[T any](fn T) (*clonedFunc[T], error) {
return nil, fmt.Errorf("not a function, kind: %v", fnv.Kind())
}

originalCode, err := funcSlice(fn)
originalCode, err := static.GetInfo().FuncSlice(fn)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -64,7 +66,7 @@ func cloneFunc[T any](fn T) (*clonedFunc[T], error) {
return nil, errors.New("failed to allocate memory for cloned function")
}

cacheflush(newCode)
cacheflush.Flush(newCode)

// This seems too complicated. The idea is to take our newly allocated
// buffer of machine instructions and convince Go that it's really a
Expand Down Expand Up @@ -128,16 +130,9 @@ func (a *allocator) init(startSize int) error {
const absMinAddress = 0x100000

func initMallocBackend() (malloc.ArenaBackend, error) {
var text, etext uintptr
var end uintptr
pc, _, _, _ := runtime.Caller(0)
datap := findfunc(pc).datap
if datap != nil {
text = datap.text
etext = datap.etext
end = datap.end
}
if text == 0 || etext == 0 || end == 0 {
info := static.GetInfo()
text, etext := info.Text()
if text == 0 || etext == 0 || info.End == 0 {
return nil, fmt.Errorf("failed to find moduledata")
}

Expand All @@ -149,7 +144,7 @@ func initMallocBackend() (malloc.ArenaBackend, error) {
//
// Use the size of the existing text segment so there's enough space to
// clone every statically-linked function.
size := (etext - text + pageSize - 1) &^ (pageSize - 1)
size := etext - text

// Cloned functions need to be near the existing text and data
// segments so that they can be reached by the same
Expand All @@ -163,8 +158,8 @@ func initMallocBackend() (malloc.ArenaBackend, error) {
// If there's an ideal range for the architecture, try that first.
if idealCloneDistance > 0 {
// Search before text
minAddress := end - idealCloneDistance
if minAddress > end || minAddress < absMinAddress {
minAddress := info.End - idealCloneDistance
if minAddress > info.End || minAddress < absMinAddress {
minAddress = absMinAddress
}
be := tryBackendRange(size, minAddress, text-pageSize-size)
Expand All @@ -177,15 +172,15 @@ func initMallocBackend() (malloc.ArenaBackend, error) {
if maxAddress < text {
maxAddress = math.MaxUint
}
be = tryBackendRange(size, end, maxAddress)
be = tryBackendRange(size, info.End, maxAddress)
if be != nil {
return be, nil
}
}

// Nothing in the ideal range, so search within the acceptable range
minAddress := end - maxCloneDistance
if minAddress > end || minAddress < absMinAddress {
minAddress := info.End - maxCloneDistance
if minAddress > info.End || minAddress < absMinAddress {
minAddress = absMinAddress
}
be := tryBackendRange(size, minAddress, text-pageSize-size)
Expand All @@ -197,7 +192,7 @@ func initMallocBackend() (malloc.ArenaBackend, error) {
if maxAddress < text {
maxAddress = math.MaxUint
}
be = tryBackendRange(size, end, maxAddress)
be = tryBackendRange(size, info.End, maxAddress)
if be != nil {
return be, nil
}
Expand Down
46 changes: 24 additions & 22 deletions clone_mprotect_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,33 @@ package redefine

/*
#include <pthread.h>
#include <string.h>

// jit_memcpy copies len bytes from src to dst within a JIT write-protected
// scope: pthread_jit_write_protect_np(0) before and (1) after, with an
// I-cache flush in between. The entire operation runs in C so that the return
// to Go code happens after MAP_JIT pages are back in execute mode.
static void jit_memcpy(void *dst, const void *src, size_t len) {
pthread_jit_write_protect_np(0);
memcpy(dst, src, len);
__builtin___clear_cache(dst, (char *)dst + len);
pthread_jit_write_protect_np(1);
}
*/
import "C"
import (
"runtime"

"golang.org/x/sys/unix"
)
import "unsafe"

func mprotectHook(inner func(int) error) func(int) error {
return func(prot int) error {
// Instead of calling mprotect, just use Darwin's
// pthread_jit_write_protect_np which is effectively the same
// in this case.

// This value is thread specific, so lock the running goroutine
// to the system thread. This assumes that this function is
// called in BeginMutate/EndMutate pairs.
return inner
}

if prot&unix.PROT_WRITE != 0 {
runtime.LockOSThread()
C.pthread_jit_write_protect_np(0)
} else {
C.pthread_jit_write_protect_np(1)
runtime.UnlockOSThread()
}
return nil
}
// writeJITCode copies src into dst on MAP_JIT pages. The JIT write-protect
// toggle and I-cache flush happen entirely in C, so the return to Go (which
// executes from the duplicate MAP_JIT text) is always in execute mode.
func writeJITCode(dst, src []byte) {
C.jit_memcpy(
unsafe.Pointer(unsafe.SliceData(dst)),
unsafe.Pointer(unsafe.SliceData(src)),
C.size_t(len(src)),
)
}
5 changes: 2 additions & 3 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@
// is a fun experiment, but do not use it for production code.
//
// This project is fundamentally non-portable. OS/Arch support:
// - Full support: Linux/amd64, Windows/amd64, Darwin/amd64, Linux/arm64, Windows/arm64
// - Might work (untested, but it compiles): FreeBSD/amd64, OpenBSD/amd64, NetBSD/amd64
// - Known broken: Darwin/arm64 (EACCES errors from mprotect)
// - Full support: Linux, Windows, Darwin/MacOS on amd64 and arm64
// - Might work (untested, but it compiles): FreeBSD, OpenBSD, NetBSD on amd64
//
// Other limitations:
// - Relies on internal Go APIs that can break at any time
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//go:build arm64

package redefine
package cacheflush

import "unsafe"

Expand All @@ -11,7 +11,7 @@ static void cacheflush(char *start, char *end) {
*/
import "C"

func cacheflush(buf []byte) {
func Flush(buf []byte) {
start := unsafe.Pointer(unsafe.SliceData(buf))
end := unsafe.Pointer(uintptr(len(buf)) + uintptr(start))
C.cacheflush((*C.char)(start), (*C.char)(end))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
//go:build arm64 && !cgo

package redefine
package cacheflush

// arm64 requires a C compiler to flush the instruction cache.
// Install a C compiler and build with CGO_ENABLED=1.
func cacheflush(buf []byte) {
func Flush(buf []byte) {
arm64_requires_cgo_for_instruction_cache_flushing()
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//go:build !arm64

package redefine
package cacheflush

// This isn't needed on amd64. The arm64 version uses the C builtin which is a
// no-op, but avoiding cgo makes cross-compiling easier.
func cacheflush(buf []byte) {}
func Flush(buf []byte) {}
115 changes: 115 additions & 0 deletions internal/mach/vm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
//go:build darwin && arm64

package mach

/*
#include <mach/mach.h>
#include <mach/mach_vm.h>
*/
import "C"
import (
"fmt"
"unsafe"
)

type KernErr int

func (e KernErr) Error() string {
// Error strings from https://web.mit.edu/darwin/src/modules/xnu/osfmk/man/vm_remap.html and kern_return.h
switch e {
case C.KERN_INVALID_ADDRESS:
return "Specified address is not currently valid."
case C.KERN_NO_SPACE:
return "There is not enough space in the task's address space to allocate the new region for the memory object."
case C.KERN_PROTECTION_FAILURE:
return "Specified memory is valid, but the backing memory manager is not permitted by the requesting task."
}
return fmt.Sprintf("Unknown error code: %d", e)
}

const (
VmProtNone = C.VM_PROT_NONE
VmProtRead = C.VM_PROT_READ
VmProtWrite = C.VM_PROT_WRITE
VmProtExecute = C.VM_PROT_EXECUTE
)

type VmInfo struct {
Addr unsafe.Pointer
Size uintptr
Prot int
MaxProt int
}

// VmRemap makes a new virtual memory mapping of srcAddr. If addr is 0 then the
// new mapping will be allocated anywhere, otherwise the page will be requested
// at exactly the given address and may overwrite a previously existing mapping
// at that address.
func VmRemap(addr uintptr, srcAddr uintptr, size uintptr) (*VmInfo, error) {
info := VmInfo{
Size: size,
}

var vmAddr C.mach_vm_address_t
vmAddr = C.mach_vm_address_t(addr)

var flags int
if addr == 0 {
flags |= C.VM_FLAGS_ANYWHERE
} else {
flags |= C.VM_FLAGS_FIXED | C.VM_FLAGS_OVERWRITE
}

var curProt, maxProt C.vm_prot_t

ret := C.mach_vm_remap(
C.mach_task_self_,
&vmAddr,
C.mach_vm_address_t(size),
0,
C.int(flags),
C.mach_task_self_,
C.mach_vm_address_t(srcAddr),
0,
&curProt,
&maxProt,
C.VM_INHERIT_NONE,
)

if ret != 0 {
return nil, KernErr(ret)
}

info.Addr = unsafe.Pointer(uintptr(vmAddr))
info.Prot = int(curProt)
info.MaxProt = int(maxProt)

return &info, nil
}

// Slice returns a byte slice that uses the backing memory referenced in VmInfo.
func (vmi *VmInfo) Slice() []byte {
if vmi.Addr == nil || vmi.Size == 0 {
return nil
}
return unsafe.Slice((*byte)(vmi.Addr), vmi.Size)
}

// Unmap deallocates the referenced memory.
func (vmi *VmInfo) Unmap() error {
ret := C.mach_vm_deallocate(
C.mach_task_self_,
C.mach_vm_address_t(uintptr(vmi.Addr)),
C.mach_vm_address_t(vmi.Size),
)
if ret != 0 {
return KernErr(ret)
}

vmi.Addr = nil
vmi.Size = 0
vmi.Prot = 0
vmi.MaxProt = 0

return nil
}
Loading
Loading