Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
876d3b7
Only image mount
thomasjpfan Jan 26, 2026
2caf2ba
Less changes
thomasjpfan Jan 26, 2026
ffe8c14
Introduce detach back
thomasjpfan Jan 27, 2026
1175ead
Make sure terminate actually calls terminate
thomasjpfan Jan 27, 2026
4cb11a9
Remove the need for ALPN workaround
thomasjpfan Jan 27, 2026
a994b38
Add docs
thomasjpfan Jan 27, 2026
0a07097
Fix lint
thomasjpfan Jan 27, 2026
42a301a
Simplify tls implemetnation
thomasjpfan Jan 27, 2026
812a561
Give terminate error a higher priority
thomasjpfan Jan 27, 2026
9c89b64
Make diff smaller
thomasjpfan Jan 27, 2026
5dc47e5
Less diff
thomasjpfan Jan 27, 2026
982ccc2
Ensure that sandbox is attached before running any operation
thomasjpfan Jan 27, 2026
6c3a2a8
Less diff
thomasjpfan Jan 27, 2026
3eba12d
Remove ts detach
thomasjpfan Jan 27, 2026
b37410f
Allow for terminate to happen
thomasjpfan Jan 27, 2026
d06531d
Use two mutexes
thomasjpfan Jan 27, 2026
367125b
Use atomic bool
thomasjpfan Jan 27, 2026
831f1dd
Remove profile.TaskCommandRouterInsecure
thomasjpfan Jan 28, 2026
7365519
Terminate no longer detaches
thomasjpfan Jan 28, 2026
f9c6f6c
Add detach everywhere
thomasjpfan Jan 28, 2026
10db500
Update examples
thomasjpfan Jan 28, 2026
c395951
Add changelog
thomasjpfan Jan 28, 2026
e49f593
Add example to readme
thomasjpfan Jan 28, 2026
ec85b2b
Add ensureAttached to be extra safe
thomasjpfan Jan 28, 2026
11b08c3
Use a better error message
thomasjpfan Jan 28, 2026
fb06f43
Add detach and params to Sandbox.Terminate
thomasjpfan Jan 29, 2026
a4a0084
Update changelog
thomasjpfan Jan 29, 2026
f08632d
Fix linter
thomasjpfan Jan 29, 2026
ac9e6f0
Do not detach
thomasjpfan Jan 29, 2026
4c46034
Add terminate back in
thomasjpfan Jan 29, 2026
a07297a
Switch order
thomasjpfan Jan 29, 2026
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ Both client libraries are pre-1.0, and they have separate versioning.
## Unreleased

- Upgraded the internal handling of Sandbox exec to use the new command router interface, which brings greatly improved performance and reliability for exec operations.
- Go's `Sandbox` requires calling `Sandbox.Detach` after you are done interacting with the sandbox. `Sandbox.Detach` disconnects your client from the sandbox and cleans up any resources associated with the connection. `Sandbox.Terminate` now accepts a detach bool to also detach after terminating the
sandbox.

## modal-js/v0.6.0, modal-go/v0.6.0

Expand Down
1 change: 1 addition & 0 deletions modal-go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ We also provide a number of examples:
- [Create a Sandbox using a private image from AWS ECR](./examples/sandbox-private-image/main.go)
- [Take a snapshot of the filesystem of a Sandbox](./examples/sandbox-filesystem-snapshot/main.go)
- [Execute Sandbox commands](./examples/sandbox-exec/main.go)
- [Mounting an Image in the Sandbox Filesystem](./examples/sandbox-image-mount/main.go)
- [Running a coding agent in a Sandbox](./examples/sandbox-agent/main.go)
- [Check the status and exit code of a Sandbox](./examples/sandbox-poll/main.go)
- [Access Sandbox filesystem](./examples/sandbox-filesystem/main.go)
Expand Down
3 changes: 3 additions & 0 deletions modal-go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ type retryCallOption struct {
const (
apiEndpoint = "api.modal.com:443"
maxMessageSize = 100 * 1024 * 1024 // 100 MB
windowSize = 64 * 1024 * 1024 // 64 MiB
defaultRetryAttempts = 3
defaultRetryBaseDelay = 100 * time.Millisecond
defaultRetryMaxDelay = 1 * time.Second
Expand Down Expand Up @@ -334,6 +335,8 @@ func newClient(ctx context.Context, profile Profile, c *Client, customUnaryInter
conn, err := grpc.NewClient(
target,
grpc.WithTransportCredentials(creds),
grpc.WithInitialWindowSize(windowSize),
grpc.WithInitialConnWindowSize(windowSize),
grpc.WithDefaultCallOptions(
grpc.MaxCallRecvMsgSize(maxMessageSize),
grpc.MaxCallSendMsgSize(maxMessageSize),
Expand Down
10 changes: 10 additions & 0 deletions modal-go/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package modal
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"

Expand All @@ -22,6 +23,15 @@ type Profile struct {
LogLevel string
}

func (p Profile) isLocalhost() bool {
parsedURL, err := url.Parse(p.ServerURL)
if err != nil {
return false
}
hostname := parsedURL.Hostname()
return hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" || hostname == "172.21.0.1"
}

// rawProfile mirrors the TOML structure on disk.
type rawProfile struct {
ServerURL string `toml:"server_url"`
Expand Down
6 changes: 6 additions & 0 deletions modal-go/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,9 @@ func TestGetConfigPath_WithoutEnvVar(t *testing.T) {
expectedPath := filepath.Join(home, ".modal.toml")
g.Expect(path).Should(gomega.Equal(expectedPath))
}

func TestProfileIsLocalhost(t *testing.T) {
g := gomega.NewWithT(t)
p := Profile{ServerURL: "http://localhost:8889"}
g.Expect(p.isLocalhost()).Should(gomega.BeTrue())
}
9 changes: 9 additions & 0 deletions modal-go/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,12 @@ type SandboxTimeoutError struct {
func (e SandboxTimeoutError) Error() string {
return "SandboxTimeoutError: " + e.Exception
}

// SandboxDetached is returned when running an operation on a detached sandbox object.
type SandboxDetached struct {
Exception string
}

func (e SandboxDetached) Error() string {
return "SandboxDetached: " + e.Exception
}
2 changes: 1 addition & 1 deletion modal-go/examples/image-building/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func main() {
log.Fatal(err)
}
defer func() {
if err := sb.Terminate(context.Background()); err != nil {
if err := sb.Terminate(context.Background(), true, nil); err != nil {
log.Fatalf("Failed to terminate Sandbox %s: %v", sb.SandboxID, err)
}
}()
Expand Down
2 changes: 1 addition & 1 deletion modal-go/examples/sandbox-agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func main() {
fmt.Println("Started Sandbox:", sb.SandboxID)

defer func() {
if err := sb.Terminate(context.Background()); err != nil {
if err := sb.Terminate(context.Background(), true, nil); err != nil {
log.Fatalf("Failed to terminate Sandbox %s: %v", sb.SandboxID, err)
}
}()
Expand Down
2 changes: 1 addition & 1 deletion modal-go/examples/sandbox-cloud-bucket/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func main() {
log.Fatalf("Failed to create Sandbox: %v", err)
}
defer func() {
if err := sb.Terminate(context.Background()); err != nil {
if err := sb.Terminate(context.Background(), true, nil); err != nil {
log.Fatalf("Failed to terminate Sandbox %s: %v", sb.SandboxID, err)
}
}()
Expand Down
2 changes: 1 addition & 1 deletion modal-go/examples/sandbox-connect-token/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func main() {
log.Fatalf("Failed to create Sandbox: %v", err)
}
defer func() {
if err := sb.Terminate(context.Background()); err != nil {
if err := sb.Terminate(context.Background(), true, nil); err != nil {
log.Fatalf("Failed to terminate Sandbox %s: %v", sb.SandboxID, err)
}
}()
Expand Down
2 changes: 1 addition & 1 deletion modal-go/examples/sandbox-exec/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func main() {
}
fmt.Println("Started Sandbox:", sb.SandboxID)
defer func() {
if err := sb.Terminate(context.Background()); err != nil {
if err := sb.Terminate(context.Background(), true, nil); err != nil {
log.Fatalf("Failed to terminate Sandbox %s: %v", sb.SandboxID, err)
}
}()
Expand Down
9 changes: 6 additions & 3 deletions modal-go/examples/sandbox-filesystem-snapshot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func main() {
}
fmt.Printf("Started Sandbox: %s\n", sb.SandboxID)
defer func() {
if err := sb.Terminate(context.Background()); err != nil {
if err := sb.Terminate(context.Background(), true, nil); err != nil {
Copy link

Choose a reason for hiding this comment

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

Deferred terminate fails after manual detach in examples

Medium Severity

Deferred Terminate calls fail because the target sandbox is already detached. This occurs when a sandbox is explicitly detached or terminated with detach=true before the deferred call executes, leading to a fatal log.

Additional Locations (1)

Fix in Cursor Fix in Web

log.Fatalf("Failed to terminate Sandbox %s: %v", sb.SandboxID, err)
}
}()
Expand All @@ -52,11 +52,14 @@ func main() {
}
fmt.Printf("Filesystem snapshot created with Image ID: %s\n", snapshotImage.ImageID)

err = sb.Terminate(ctx)
err = sb.Terminate(ctx, false, nil)
if err != nil {
log.Fatalf("Failed to terminate Sandbox %s: %v", sb.SandboxID, err)
}
fmt.Println("Terminated first Sandbox")
if err := sb.Detach(); err != nil {
log.Fatalf("Failed to detach Sandbox %s: %v", sb.SandboxID, err)
}

// Create new Sandbox from snapshot Image
sb2, err := mc.Sandboxes.Create(ctx, app, snapshotImage, nil)
Expand All @@ -66,7 +69,7 @@ func main() {
fmt.Printf("Started new Sandbox from snapshot: %s\n", sb2.SandboxID)

defer func() {
if err := sb2.Terminate(context.Background()); err != nil {
if err := sb2.Terminate(context.Background(), true, nil); err != nil {
log.Fatalf("Failed to terminate Sandbox %s: %v", sb2.SandboxID, err)
}
}()
Expand Down
2 changes: 1 addition & 1 deletion modal-go/examples/sandbox-filesystem/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func main() {
fmt.Printf("Started Sandbox: %s\n", sb.SandboxID)

defer func() {
if err := sb.Terminate(context.Background()); err != nil {
if err := sb.Terminate(context.Background(), true, nil); err != nil {
log.Fatalf("Failed to terminate Sandbox %s: %v", sb.SandboxID, err)
}
}()
Expand Down
2 changes: 1 addition & 1 deletion modal-go/examples/sandbox-gpu/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func main() {
}
fmt.Printf("Started Sandbox with A10G GPU: %s\n", sb.SandboxID)
defer func() {
if err := sb.Terminate(context.Background()); err != nil {
if err := sb.Terminate(context.Background(), true, nil); err != nil {
log.Fatalf("Failed to terminate Sandbox %s: %v", sb.SandboxID, err)
}
}()
Expand Down
142 changes: 142 additions & 0 deletions modal-go/examples/sandbox-image-mount/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// This example shows how to mount Images in the Sandbox filesystem and take snapshots
// of them.
//
// The feature is still experimental in the sense that the API is subject to change.
//
// High level, it allows you to:
// - Mount any Modal Image at a specific directory within the Sandbox filesystem.
// - Take a snapshot of that directory, which will create a new Modal Image with
// the updated contents of the directory.
//
// You can only snapshot directories that have previously been mounted using
// `Sandbox.ExperimentalMountImage`. If you want to mount an empty directory,
// you can pass nil as the image parameter.
//
// For exmaple, you can use this to mount user specific dependencies into a running
// Sandbox, that is started with a base Image with shared system dependencies. This
// way, you can update system dependencies and user projects independently.

package main

import (
"context"
"fmt"
"io"
"log"

"github.com/modal-labs/libmodal/modal-go"
)

func main() {
ctx := context.Background()
mc, err := modal.NewClient()
if err != nil {
log.Fatalf("Failed to create client: %v", err)
}

app, err := mc.Apps.FromName(ctx, "libmodal-example", &modal.AppFromNameParams{CreateIfMissing: true})
if err != nil {
log.Fatalf("Failed to get or create App: %v", err)
}

// The base Image you use for the Sandbox must have a /usr/bin/mount binary.
baseImage := mc.Images.FromRegistry("debian:12-slim", nil).DockerfileCommands([]string{
"RUN apt-get update && apt-get install -y git",
}, nil)

sb, err := mc.Sandboxes.Create(ctx, app, baseImage, nil)
if err != nil {
log.Fatalf("Failed to create Sandbox: %v", err)
}
defer func() {
if err := sb.Terminate(context.Background(), true, nil); err != nil {
log.Fatalf("Failed to terminate Sandbox %s: %v", sb.SandboxID, err)
}
}()
fmt.Printf("Started first Sandbox: %s\n", sb.SandboxID)

// You must mount an Image at a directory in the Sandbox filesystem before you
// can snapshot it. You can pass nil as the image parameter to mount an
// empty directory.
//
// The target directory must exist before you can mount it:
mkdirProc, err := sb.Exec(ctx, []string{"mkdir", "-p", "/repo"}, nil)
if err != nil {
log.Fatalf("Failed to exec mkdir: %v", err)
}
if exitCode, err := mkdirProc.Wait(ctx); err != nil || exitCode != 0 {
log.Fatalf("Failed to wait for mkdir: exit code: %d, err: %v", exitCode, err)
}
if err := sb.ExperimentalMountImage(ctx, "/repo", nil); err != nil {
log.Fatalf("Failed to mount image: %v", err)
}

gitClone, err := sb.Exec(ctx, []string{
"git",
"clone",
"https://github.com/modal-labs/libmodal.git",
"/repo",
}, nil)
if err != nil {
log.Fatalf("Failed to exec git clone: %v", err)
}
if exitCode, err := gitClone.Wait(ctx); err != nil || exitCode != 0 {
log.Fatalf("Failed to wait for git clone: exit code: %d, err: %v", exitCode, err)
}

repoSnapshot, err := sb.ExperimentalSnapshotDirectory(ctx, "/repo")
if err != nil {
log.Fatalf("Failed to snapshot directory: %v", err)
}
fmt.Printf("Took a snapshot of the /repo directory, Image ID: %s\n", repoSnapshot.ImageID)

if err := sb.Terminate(ctx, false, nil); err != nil {
log.Fatalf("Failed to terminate Sandbox: %v", err)
}
if err := sb.Detach(); err != nil {
log.Fatalf("Failed to detach Sandbox %s: %v", sb.SandboxID, err)
}

// Start a new Sandbox, and mount the repo directory:
sb2, err := mc.Sandboxes.Create(ctx, app, baseImage, nil)
if err != nil {
log.Fatalf("Failed to create second Sandbox: %v", err)
}
defer func() {
if err := sb2.Terminate(context.Background(), true, nil); err != nil {
log.Fatalf("Failed to terminate Sandbox %s: %v", sb2.SandboxID, err)
}
}()
fmt.Printf("Started second Sandbox: %s\n", sb2.SandboxID)

mkdirProc2, err := sb2.Exec(ctx, []string{"mkdir", "-p", "/repo"}, nil)
if err != nil {
log.Fatalf("Failed to exec mkdir in sb2: %v", err)
}
if exitCode, err := mkdirProc2.Wait(ctx); err != nil || exitCode != 0 {
log.Fatalf("Failed to wait for mkdir in sb2: exit code: %d, err: %v", exitCode, err)
}
if err := sb2.ExperimentalMountImage(ctx, "/repo", repoSnapshot); err != nil {
log.Fatalf("Failed to mount snapshot in sb2: %v", err)
}

repoLs, err := sb2.Exec(ctx, []string{"ls", "/repo"}, nil)
if err != nil {
log.Fatalf("Failed to exec ls: %v", err)
}
if exitCode, err := repoLs.Wait(ctx); err != nil || exitCode != 0 {
log.Fatalf("Failed to wait for ls: exit code: %d, err: %v", exitCode, err)
}
output, err := io.ReadAll(repoLs.Stdout)
if err != nil {
log.Fatalf("Failed to read stdout: %v", err)
}
fmt.Printf("Contents of /repo directory in new Sandbox sb2:\n%s", output)

if err := sb2.Terminate(ctx, true, nil); err != nil {
log.Fatalf("Failed to terminate sb2: %v", err)
}
if err := mc.Images.Delete(ctx, repoSnapshot.ImageID, nil); err != nil {
log.Fatalf("Failed to delete snapshot image: %v", err)
}
}
2 changes: 1 addition & 1 deletion modal-go/examples/sandbox-named/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func main() {
log.Fatalf("Failed to create Sandbox: %v", err)
}
defer func() {
if err := sb.Terminate(context.Background()); err != nil {
if err := sb.Terminate(context.Background(), true, nil); err != nil {
log.Fatalf("Failed to terminate Sandbox %s: %v", sb.SandboxID, err)
}
}()
Expand Down
2 changes: 1 addition & 1 deletion modal-go/examples/sandbox-poll/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func main() {
}
fmt.Printf("Started Sandbox: %s\n", sb.SandboxID)
defer func() {
if err := sb.Terminate(context.Background()); err != nil {
if err := sb.Terminate(context.Background(), true, nil); err != nil {
log.Fatalf("Failed to terminate Sandbox %s: %v", sb.SandboxID, err)
}
}()
Expand Down
2 changes: 1 addition & 1 deletion modal-go/examples/sandbox-prewarm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func main() {
log.Fatalf("Failed to create Sandbox: %v", err)
}
defer func() {
if err := sb.Terminate(context.Background()); err != nil {
if err := sb.Terminate(context.Background(), true, nil); err != nil {
log.Fatalf("Failed to terminate Sandbox %s: %v", sb.SandboxID, err)
}
}()
Expand Down
2 changes: 1 addition & 1 deletion modal-go/examples/sandbox-private-image/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func main() {
}
fmt.Printf("Sandbox: %s\n", sb.SandboxID)
defer func() {
if err := sb.Terminate(context.Background()); err != nil {
if err := sb.Terminate(context.Background(), true, nil); err != nil {
log.Fatalf("Failed to terminate Sandbox %s: %v", sb.SandboxID, err)
}
}()
Expand Down
2 changes: 1 addition & 1 deletion modal-go/examples/sandbox-proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func main() {
}
fmt.Printf("Created Sandbox with proxy: %s\n", sb.SandboxID)
defer func() {
if err := sb.Terminate(context.Background()); err != nil {
if err := sb.Terminate(context.Background(), true, nil); err != nil {
log.Fatalf("Failed to terminate Sandbox %s: %v", sb.SandboxID, err)
}
}()
Expand Down
2 changes: 1 addition & 1 deletion modal-go/examples/sandbox-secrets/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func main() {
}
fmt.Printf("Sandbox created: %s\n", sb.SandboxID)
defer func() {
if err := sb.Terminate(context.Background()); err != nil {
if err := sb.Terminate(context.Background(), true, nil); err != nil {
log.Fatalf("Failed to terminate Sandbox %s: %v", sb.SandboxID, err)
}
}()
Expand Down
5 changes: 4 additions & 1 deletion modal-go/examples/sandbox-tunnels/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,12 @@ func main() {
log.Fatalf("Failed to create Sandbox: %v", err)
}
defer func() {
if err := sb.Terminate(context.Background()); err != nil {
if err := sb.Terminate(context.Background(), true, nil); err != nil {
log.Fatalf("Failed to terminate Sandbox %s: %v", sb.SandboxID, err)
}
if err := sb.Detach(); err != nil {
log.Fatalf("Failed to detach Sandbox %s: %v", sb.SandboxID, err)
}
}()

fmt.Printf("Sandbox created: %s\n", sb.SandboxID)
Expand Down
Loading