Skip to content

Commit ac41d27

Browse files
committed
Refine sb.Terminate and .Detach
1 parent 0c83a2b commit ac41d27

File tree

4 files changed

+240
-12
lines changed

4 files changed

+240
-12
lines changed

modal-go/sandbox.go

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -716,23 +716,38 @@ func (sb *Sandbox) getOrCreateCommandRouterClient(ctx context.Context, taskID st
716716
return sb.commandRouterClient, nil
717717
}
718718

719-
// Close closes any resources associated with the Sandbox.
720-
// This should be called when the Sandbox is no longer needed
721-
// in the local client. The sandbox can still be running and
722-
// accessed in other clients.
723-
func (sb *Sandbox) Close() {
719+
// Detach disconnects from the running Sandbox, and closies the Stdin/Stdout/Stderr streams.
720+
// The remote Sandbox continues running and can be accessed from other clients.
721+
func (sb *Sandbox) Detach() {
724722
sb.mu.Lock()
725723
defer sb.mu.Unlock()
726724

727725
if sb.commandRouterClient != nil {
728726
_ = sb.commandRouterClient.Close()
729727
sb.commandRouterClient = nil
730728
}
729+
730+
if sb.Stdin != nil {
731+
_ = sb.Stdin.Close()
732+
}
733+
if sb.Stdout != nil {
734+
_ = sb.Stdout.Close()
735+
}
736+
if sb.Stderr != nil {
737+
_ = sb.Stderr.Close()
738+
}
731739
}
732740

733741
// Terminate stops the Sandbox.
742+
// The Stdin, Stdout, Stderr streams are not closed.
734743
func (sb *Sandbox) Terminate(ctx context.Context) error {
735-
sb.Close()
744+
sb.mu.Lock()
745+
if sb.commandRouterClient != nil {
746+
_ = sb.commandRouterClient.Close()
747+
sb.commandRouterClient = nil
748+
}
749+
sb.mu.Unlock()
750+
736751
_, err := sb.client.cpClient.SandboxTerminate(ctx, pb.SandboxTerminateRequest_builder{
737752
SandboxId: sb.SandboxID,
738753
}.Build())

modal-go/test/sandbox_test.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1076,3 +1076,126 @@ func TestSandboxExecAfterTerminate(t *testing.T) {
10761076
_, err = sb.Exec(ctx, []string{"echo", "hello"}, nil)
10771077
g.Expect(err).To(gomega.HaveOccurred())
10781078
}
1079+
1080+
func TestSandboxDetachThenExec(t *testing.T) {
1081+
t.Parallel()
1082+
g := gomega.NewWithT(t)
1083+
ctx := context.Background()
1084+
tc := newTestClient(t)
1085+
1086+
app, err := tc.Apps.FromName(ctx, "libmodal-test", &modal.AppFromNameParams{CreateIfMissing: true})
1087+
g.Expect(err).ToNot(gomega.HaveOccurred())
1088+
1089+
image := tc.Images.FromRegistry("alpine:3.21", nil)
1090+
1091+
sb, err := tc.Sandboxes.Create(ctx, app, image, nil)
1092+
g.Expect(err).ToNot(gomega.HaveOccurred())
1093+
defer terminateSandbox(g, sb)
1094+
1095+
p1, err := sb.Exec(ctx, []string{"echo", "first"}, nil)
1096+
g.Expect(err).ToNot(gomega.HaveOccurred())
1097+
exitCode1, err := p1.Wait(ctx)
1098+
g.Expect(err).ToNot(gomega.HaveOccurred())
1099+
g.Expect(exitCode1).To(gomega.Equal(0))
1100+
1101+
sb.Detach()
1102+
1103+
// Second exec creates new command router client
1104+
p2, err := sb.Exec(ctx, []string{"echo", "second"}, nil)
1105+
g.Expect(err).ToNot(gomega.HaveOccurred())
1106+
exitCode2, err := p2.Wait(ctx)
1107+
g.Expect(err).ToNot(gomega.HaveOccurred())
1108+
g.Expect(exitCode2).To(gomega.Equal(0))
1109+
}
1110+
1111+
func TestSandboxDetachIsNonDestructive(t *testing.T) {
1112+
t.Parallel()
1113+
g := gomega.NewWithT(t)
1114+
ctx := context.Background()
1115+
tc := newTestClient(t)
1116+
1117+
app, err := tc.Apps.FromName(ctx, "libmodal-test", &modal.AppFromNameParams{CreateIfMissing: true})
1118+
g.Expect(err).ToNot(gomega.HaveOccurred())
1119+
1120+
image := tc.Images.FromRegistry("alpine:3.21", nil)
1121+
1122+
sb, err := tc.Sandboxes.Create(ctx, app, image, nil)
1123+
g.Expect(err).ToNot(gomega.HaveOccurred())
1124+
defer terminateSandbox(g, sb)
1125+
1126+
sandboxID := sb.SandboxID
1127+
1128+
sb.Detach()
1129+
1130+
sbFromID, err := tc.Sandboxes.FromID(ctx, sandboxID)
1131+
g.Expect(err).ToNot(gomega.HaveOccurred())
1132+
defer sbFromID.Detach()
1133+
g.Expect(sbFromID.SandboxID).To(gomega.Equal(sandboxID))
1134+
1135+
p, err := sbFromID.Exec(ctx, []string{"echo", "still running"}, nil)
1136+
g.Expect(err).ToNot(gomega.HaveOccurred())
1137+
exitCode, err := p.Wait(ctx)
1138+
g.Expect(err).ToNot(gomega.HaveOccurred())
1139+
g.Expect(exitCode).To(gomega.Equal(0))
1140+
}
1141+
1142+
func TestSandboxDetachIsIdempotent(t *testing.T) {
1143+
t.Parallel()
1144+
g := gomega.NewWithT(t)
1145+
ctx := context.Background()
1146+
tc := newTestClient(t)
1147+
1148+
app, err := tc.Apps.FromName(ctx, "libmodal-test", &modal.AppFromNameParams{CreateIfMissing: true})
1149+
g.Expect(err).ToNot(gomega.HaveOccurred())
1150+
1151+
image := tc.Images.FromRegistry("alpine:3.21", nil)
1152+
1153+
sb, err := tc.Sandboxes.Create(ctx, app, image, nil)
1154+
g.Expect(err).ToNot(gomega.HaveOccurred())
1155+
defer terminateSandbox(g, sb)
1156+
1157+
sb.Detach()
1158+
sb.Detach()
1159+
sb.Detach()
1160+
}
1161+
1162+
func TestSandboxDetachThenTerminate(t *testing.T) {
1163+
t.Parallel()
1164+
g := gomega.NewWithT(t)
1165+
ctx := context.Background()
1166+
tc := newTestClient(t)
1167+
1168+
app, err := tc.Apps.FromName(ctx, "libmodal-test", &modal.AppFromNameParams{CreateIfMissing: true})
1169+
g.Expect(err).ToNot(gomega.HaveOccurred())
1170+
1171+
image := tc.Images.FromRegistry("alpine:3.21", nil)
1172+
1173+
sb, err := tc.Sandboxes.Create(ctx, app, image, nil)
1174+
g.Expect(err).ToNot(gomega.HaveOccurred())
1175+
defer terminateSandbox(g, sb)
1176+
1177+
sb.Detach()
1178+
1179+
err = sb.Terminate(ctx)
1180+
g.Expect(err).ToNot(gomega.HaveOccurred())
1181+
}
1182+
1183+
func TestSandboxTerminateThenDetach(t *testing.T) {
1184+
t.Parallel()
1185+
g := gomega.NewWithT(t)
1186+
ctx := context.Background()
1187+
tc := newTestClient(t)
1188+
1189+
app, err := tc.Apps.FromName(ctx, "libmodal-test", &modal.AppFromNameParams{CreateIfMissing: true})
1190+
g.Expect(err).ToNot(gomega.HaveOccurred())
1191+
1192+
image := tc.Images.FromRegistry("alpine:3.21", nil)
1193+
1194+
sb, err := tc.Sandboxes.Create(ctx, app, image, nil)
1195+
g.Expect(err).ToNot(gomega.HaveOccurred())
1196+
1197+
err = sb.Terminate(ctx)
1198+
g.Expect(err).ToNot(gomega.HaveOccurred())
1199+
1200+
sb.Detach()
1201+
}

modal-js/src/sandbox.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -941,20 +941,30 @@ export class Sandbox {
941941
}
942942

943943
/**
944-
* Close any resources associated with the Sandbox.
945-
* This should be called when the Sandbox is no longer needed
946-
* in the local client. The sandbox can still be running and
947-
* accessed in other clients.
944+
* Detach from the running Sandbox, closing the stdin/stdout/stderr streams.
945+
* The remote Sandbox continues running and can be accessed from other clients.
948946
*/
949-
close(): void {
947+
detach(): void {
950948
if (this.#commandRouterClient) {
951949
this.#commandRouterClient.close();
952950
this.#commandRouterClient = undefined;
953951
}
952+
953+
this.stdin.abort().catch(() => {});
954+
this.stdout.cancel().catch(() => {});
955+
this.stderr.cancel().catch(() => {});
954956
}
955957

958+
/**
959+
* Terminate the Sandbox.
960+
* The stdin, stdout, stderr streams are not closed.
961+
*/
956962
async terminate(): Promise<void> {
957-
this.close();
963+
if (this.#commandRouterClient) {
964+
this.#commandRouterClient.close();
965+
this.#commandRouterClient = undefined;
966+
}
967+
958968
await this.#client.cpClient.sandboxTerminate({ sandboxId: this.sandboxId });
959969
this.#taskId = undefined; // Reset task ID after termination
960970
}

modal-js/test/sandbox.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,3 +1017,83 @@ test("SandboxExecAfterTerminate", async () => {
10171017

10181018
await expect(sb.exec(["echo", "hello"])).rejects.toThrow();
10191019
});
1020+
1021+
test("SandboxDetachThenExec", async () => {
1022+
const app = await tc.apps.fromName("libmodal-test", {
1023+
createIfMissing: true,
1024+
});
1025+
const image = tc.images.fromRegistry("alpine:3.21");
1026+
1027+
const sb = await tc.sandboxes.create(app, image);
1028+
onTestFinished(async () => await sb.terminate());
1029+
1030+
const p1 = await sb.exec(["echo", "first"]);
1031+
const exitCode1 = await p1.wait();
1032+
expect(exitCode1).toBe(0);
1033+
1034+
sb.detach();
1035+
1036+
// Second exec creates new command router client
1037+
const p2 = await sb.exec(["echo", "second"]);
1038+
const exitCode2 = await p2.wait();
1039+
expect(exitCode2).toBe(0);
1040+
});
1041+
1042+
test("SandboxDetachIsNonDestructive", async () => {
1043+
const app = await tc.apps.fromName("libmodal-test", {
1044+
createIfMissing: true,
1045+
});
1046+
const image = tc.images.fromRegistry("alpine:3.21");
1047+
1048+
const sb = await tc.sandboxes.create(app, image);
1049+
onTestFinished(async () => await sb.terminate());
1050+
1051+
const sandboxId = sb.sandboxId;
1052+
1053+
sb.detach();
1054+
1055+
const sbFromId = await tc.sandboxes.fromId(sandboxId);
1056+
expect(sbFromId.sandboxId).toBe(sandboxId);
1057+
1058+
const p = await sbFromId.exec(["echo", "still running"]);
1059+
const exitCode = await p.wait();
1060+
expect(exitCode).toBe(0);
1061+
});
1062+
1063+
test("SandboxDetachIsIdempotent", async () => {
1064+
const app = await tc.apps.fromName("libmodal-test", {
1065+
createIfMissing: true,
1066+
});
1067+
const image = tc.images.fromRegistry("alpine:3.21");
1068+
1069+
const sb = await tc.sandboxes.create(app, image);
1070+
onTestFinished(async () => await sb.terminate());
1071+
1072+
sb.detach();
1073+
sb.detach();
1074+
});
1075+
1076+
test("SandboxDetachThenTerminate", async () => {
1077+
const app = await tc.apps.fromName("libmodal-test", {
1078+
createIfMissing: true,
1079+
});
1080+
const image = tc.images.fromRegistry("alpine:3.21");
1081+
1082+
const sb = await tc.sandboxes.create(app, image);
1083+
onTestFinished(async () => await sb.terminate());
1084+
1085+
sb.detach();
1086+
await sb.terminate();
1087+
});
1088+
1089+
test("SandboxTerminateThenDetach", async () => {
1090+
const app = await tc.apps.fromName("libmodal-test", {
1091+
createIfMissing: true,
1092+
});
1093+
const image = tc.images.fromRegistry("alpine:3.21");
1094+
1095+
const sb = await tc.sandboxes.create(app, image);
1096+
1097+
await sb.terminate();
1098+
sb.detach();
1099+
});

0 commit comments

Comments
 (0)