nandi/frqpublic Fork 0
013945f
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Stop shipping the build tree, and let a shell be the devShell

Both containers ignore the state a local checkout carries -- 395MB of a
441MB repo, uploaded every start and excluded again on arrival -- and
both name file:///nix-cache in nix.conf, so a nix command typed by hand
reads the cache instead of rebuilding what is already on the volume.
Init went from minutes to 13 seconds.

flutter-dev bakes the devShell with `nix print-dev-env` and sources it
from .bashrc, so a shell attached to that container already is the shell:
no `nix develop`, no re-clone of the flake's git inputs, no 2.7GB fetched
again per Sandbox because the closure is an image layer. `dev` remains
for when the baked env goes stale against a flake edit.

Bare `nix develop` in there resolved to the flake's *default* shell --
libcosmic, jolt-native, a thousand crates of Rust -- which is the wrong
tree and an expensive way to learn it.

The bake is unverified: realising a devShell during an image build is
exactly what `[nix] flake = true` wanted the ptyshim for, and image
builds are still Functions under gVisor. If it cannot, the fallback is to
bake into the volume at run time instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-13T23:43:45-07:00 Browse files
013945f parent: 85852e8
modified .modal/_loader.py +28 -2
@@ -162,6 +162,7 @@ class Container:
162162 def _build_image(self) -> modal.Image:
163163 c = self.spec["container"]
164164 build = self.spec.get("build", {})
165+ nix_spec = self.spec.get("nix", {})
165166
166167 if c.get("base"):
167168 image = modal.Image.from_name(c["base"])
@@ -200,14 +201,31 @@ class Container:
200201 # the container directory -- a container that builds the repo it lives
201202 # in sets context = "../..", so include = ["."] means the whole repo.
202203 context = os.path.normpath(os.path.join(self.dir, build.get("context", ".")))
204+ # `ignore` is what keeps a build tree out of the image. A checkout that
205+ # has been built in locally carries its output -- flutter/build and the
206+ # caches beside it were 395MB of a 441MB repo -- and all of it would be
207+ # uploaded on every start only to be thrown away, since the container
208+ # builds into a volume of its own. Patterns are relative to the copied
209+ # directory, as in .dockerignore.
210+ ignore = list(build.get("ignore", []))
203211 for rel in build.get("include", ["."]):
204212 src = os.path.normpath(os.path.join(context, rel))
205213 dest = self.workdir if rel == "." else f"{self.workdir}/{rel}"
206214 if os.path.isdir(src):
207- image = image.add_local_dir(src, dest, copy=True)
215+ image = image.add_local_dir(src, dest, copy=True, ignore=ignore)
208216 else:
209217 image = image.add_local_file(src, dest, copy=True)
210218
219+ # Substituters for every nix command in the container, typed by hand or
220+ # not. Without this the only things reading a mounted cache are the
221+ # scripts that pass --extra-substituters, so an interactive shell
222+ # rebuilds from source what the volume beside it already holds.
223+ if subs := list(nix_spec.get("substituters", [])):
224+ image = image.run_commands(
225+ f"echo 'extra-substituters = {' '.join(subs)}'"
226+ " >> /etc/nix/nix.conf"
227+ )
228+
211229 # A repo copied in brings its `.git` along, and in a worktree that is a
212230 # *file* holding `gitdir: <path on the machine that copied it>`. Nix
213231 # believes it and goes looking for a checkout that is not there --
@@ -345,12 +363,20 @@ class Container:
345363 return ""
346364
347365 def open_sandbox(self) -> "modal.Sandbox":
348- """Start a Sandbox and leave it running, for `scripts/shell`."""
366+ """Start a Sandbox and leave it running, for `scripts/shell`.
367+
368+ Same workdir and the same [run] env as the real thing: a shell opened
369+ to debug a container that does not have the container's environment is
370+ a shell that reproduces something else. `command` is the one part left
371+ out, because not running it is the point.
372+ """
349373 return modal.Sandbox.create(
350374 "sleep",
351375 "infinity",
352376 app=self.app,
353377 image=self.image,
378+ workdir=self.workdir,
379+ env={k: str(v) for k, v in self.env.items()},
354380 **self.sandbox_kwargs,
355381 )
356382
@@ -162,6 +162,7 @@ class Container:
162 def _build_image(self) -> modal.Image:162 def _build_image(self) -> modal.Image:
163 c = self.spec["container"]163 c = self.spec["container"]
164 build = self.spec.get("build", {})164 build = self.spec.get("build", {})
165+ nix_spec = self.spec.get("nix", {})
165 166
166 if c.get("base"):167 if c.get("base"):
167 image = modal.Image.from_name(c["base"])168 image = modal.Image.from_name(c["base"])
@@ -200,14 +201,31 @@ class Container:
200 # the container directory -- a container that builds the repo it lives201 # the container directory -- a container that builds the repo it lives
201 # in sets context = "../..", so include = ["."] means the whole repo.202 # in sets context = "../..", so include = ["."] means the whole repo.
202 context = os.path.normpath(os.path.join(self.dir, build.get("context", ".")))203 context = os.path.normpath(os.path.join(self.dir, build.get("context", ".")))
204+ # `ignore` is what keeps a build tree out of the image. A checkout that
205+ # has been built in locally carries its output -- flutter/build and the
206+ # caches beside it were 395MB of a 441MB repo -- and all of it would be
207+ # uploaded on every start only to be thrown away, since the container
208+ # builds into a volume of its own. Patterns are relative to the copied
209+ # directory, as in .dockerignore.
210+ ignore = list(build.get("ignore", []))
203 for rel in build.get("include", ["."]):211 for rel in build.get("include", ["."]):
204 src = os.path.normpath(os.path.join(context, rel))212 src = os.path.normpath(os.path.join(context, rel))
205 dest = self.workdir if rel == "." else f"{self.workdir}/{rel}"213 dest = self.workdir if rel == "." else f"{self.workdir}/{rel}"
206 if os.path.isdir(src):214 if os.path.isdir(src):
207- image = image.add_local_dir(src, dest, copy=True)215+ image = image.add_local_dir(src, dest, copy=True, ignore=ignore)
208 else:216 else:
209 image = image.add_local_file(src, dest, copy=True)217 image = image.add_local_file(src, dest, copy=True)
210 218
219+ # Substituters for every nix command in the container, typed by hand or
220+ # not. Without this the only things reading a mounted cache are the
221+ # scripts that pass --extra-substituters, so an interactive shell
222+ # rebuilds from source what the volume beside it already holds.
223+ if subs := list(nix_spec.get("substituters", [])):
224+ image = image.run_commands(
225+ f"echo 'extra-substituters = {' '.join(subs)}'"
226+ " >> /etc/nix/nix.conf"
227+ )
228+
211 # A repo copied in brings its `.git` along, and in a worktree that is a229 # A repo copied in brings its `.git` along, and in a worktree that is a
212 # *file* holding `gitdir: <path on the machine that copied it>`. Nix230 # *file* holding `gitdir: <path on the machine that copied it>`. Nix
213 # believes it and goes looking for a checkout that is not there --231 # believes it and goes looking for a checkout that is not there --
@@ -345,12 +363,20 @@ class Container:
345 return ""363 return ""
346 364
347 def open_sandbox(self) -> "modal.Sandbox":365 def open_sandbox(self) -> "modal.Sandbox":
348- """Start a Sandbox and leave it running, for `scripts/shell`."""366+ """Start a Sandbox and leave it running, for `scripts/shell`.
367+
368+ Same workdir and the same [run] env as the real thing: a shell opened
369+ to debug a container that does not have the container's environment is
370+ a shell that reproduces something else. `command` is the one part left
371+ out, because not running it is the point.
372+ """
349 return modal.Sandbox.create(373 return modal.Sandbox.create(
350 "sleep",374 "sleep",
351 "infinity",375 "infinity",
352 app=self.app,376 app=self.app,
353 image=self.image,377 image=self.image,
378+ workdir=self.workdir,
379+ env={k: str(v) for k, v in self.env.items()},
354 **self.sandbox_kwargs,380 **self.sandbox_kwargs,
355 )381 )
356 382
modified .modal/flutter-dev/container.toml +36 -2
@@ -11,6 +11,30 @@ runtime = "sandbox"
1111 # levels up and `.` is the whole tree.
1212 context = "../.."
1313 include = ["."]
14+# `dev` is the shell you actually want. Bare `nix develop` here resolves to the
15+# flake's default shell -- libcosmic, jolt-native, a thousand crates of Rust --
16+# which is the wrong tree and an expensive way to find out.
17+# The devShell, baked in rather than entered. `print-dev-env` writes the whole
18+# environment out as shell -- PATH, the compiler, every variable mkShell sets
19+# -- and realises its inputs on the way, so the closure becomes an image layer
20+# instead of a fetch every container pays for. Sourcing it from .bashrc means a
21+# shell attached to this container *is* the devShell: no `nix develop`, no
22+# clone of the flake's git inputs, no wait.
23+#
24+# `dev` stays for the case where the baked env is stale against a flake edit.
25+commands = [
26+ "nix print-dev-env /app#flutter-desktop --accept-flake-config > /etc/devshell.sh",
27+ "echo '. /etc/devshell.sh' >> /root/.bashrc",
28+ "printf '#!/bin/sh\\nexec nix develop /app#flutter-desktop \"$@\"\\n' > /usr/local/bin/dev && chmod +x /usr/local/bin/dev",
29+]
30+# The build state a local checkout carries: 395MB of a 441MB repo, uploaded on
31+# every start and wanted by nothing out there. Flutter builds into a volume of
32+# its own, and the jolt and clojure caches are this machine's.
33+ignore = [
34+ "flutter/build", "flutter/.home", "flutter/.dart_tool",
35+ "flutter/.clojuredart", "flutter/.cpcache",
36+ ".jolt", ".cpcache", "result", "build", ".git",
37+]
1438
1539 # Two volumes doing two different jobs. `nix-cache` is the binary cache every
1640 # container here reads from and writes back to. `devshell` is the working
@@ -52,7 +76,7 @@ set -e
5276 # devShell it keeps the state of. Anything else using this volume picks its
5377 # own name and the two never meet.
5478 SHELL_DIR=/devshell/frq-flutter-desktop
55-mkdir -p "$SHELL_DIR"
79+mkdir -p "$SHELL_DIR" "$SHELL_DIR/.cache"
5680
5781 # A worktree's `.git` is a *file* naming a gitdir back on the machine that
5882 # copied it in, and nix believes it and goes looking for a path that is not
@@ -113,9 +137,19 @@ if [ -f /nix-cache/nix-cache-info ]; then
113137 nix copy --no-check-sigs --all --to file:///nix-cache
114138 fi
115139 """
116-env = { }
140+# Nix's own cache, on the volume rather than in the container. Without it
141+# every Sandbox starts empty and `nix develop` re-clones the flake's git
142+# inputs -- jolt-src, jolt-native, glimmer, nixgl and their transitives --
143+# because flake.lock pins which revision to fetch, not whether it is already
144+# on disk. Set here rather than in the command so an interactive shell into
145+# this container gets it too.
146+env = { XDG_CACHE_HOME = "/devshell/frq-flutter-desktop/.cache" }
117147
118148 [nix]
149+# Every nix command in the container reads the mounted cache, including one
150+# typed by hand in a shell. Passing --extra-substituters per command only ever
151+# covered the scripts.
152+substituters = ["file:///nix-cache"]
119153 # No devShell warming at image build time: this enters `nix develop` at run
120154 # time, on the VM, where the cache answers for its closure. The ptyshim that
121155 # warming would need under gVisor is deprecated and does not come back.
@@ -11,6 +11,30 @@ runtime = "sandbox"
11 # levels up and `.` is the whole tree.11 # levels up and `.` is the whole tree.
12 context = "../.."12 context = "../.."
13 include = ["."]13 include = ["."]
14+# `dev` is the shell you actually want. Bare `nix develop` here resolves to the
15+# flake's default shell -- libcosmic, jolt-native, a thousand crates of Rust --
16+# which is the wrong tree and an expensive way to find out.
17+# The devShell, baked in rather than entered. `print-dev-env` writes the whole
18+# environment out as shell -- PATH, the compiler, every variable mkShell sets
19+# -- and realises its inputs on the way, so the closure becomes an image layer
20+# instead of a fetch every container pays for. Sourcing it from .bashrc means a
21+# shell attached to this container *is* the devShell: no `nix develop`, no
22+# clone of the flake's git inputs, no wait.
23+#
24+# `dev` stays for the case where the baked env is stale against a flake edit.
25+commands = [
26+ "nix print-dev-env /app#flutter-desktop --accept-flake-config > /etc/devshell.sh",
27+ "echo '. /etc/devshell.sh' >> /root/.bashrc",
28+ "printf '#!/bin/sh\\nexec nix develop /app#flutter-desktop \"$@\"\\n' > /usr/local/bin/dev && chmod +x /usr/local/bin/dev",
29+]
30+# The build state a local checkout carries: 395MB of a 441MB repo, uploaded on
31+# every start and wanted by nothing out there. Flutter builds into a volume of
32+# its own, and the jolt and clojure caches are this machine's.
33+ignore = [
34+ "flutter/build", "flutter/.home", "flutter/.dart_tool",
35+ "flutter/.clojuredart", "flutter/.cpcache",
36+ ".jolt", ".cpcache", "result", "build", ".git",
37+]
14 38
15 # Two volumes doing two different jobs. `nix-cache` is the binary cache every39 # Two volumes doing two different jobs. `nix-cache` is the binary cache every
16 # container here reads from and writes back to. `devshell` is the working40 # container here reads from and writes back to. `devshell` is the working
@@ -52,7 +76,7 @@ set -e
52 # devShell it keeps the state of. Anything else using this volume picks its76 # devShell it keeps the state of. Anything else using this volume picks its
53 # own name and the two never meet.77 # own name and the two never meet.
54 SHELL_DIR=/devshell/frq-flutter-desktop78 SHELL_DIR=/devshell/frq-flutter-desktop
55-mkdir -p "$SHELL_DIR"79+mkdir -p "$SHELL_DIR" "$SHELL_DIR/.cache"
56 80
57 # A worktree's `.git` is a *file* naming a gitdir back on the machine that81 # A worktree's `.git` is a *file* naming a gitdir back on the machine that
58 # copied it in, and nix believes it and goes looking for a path that is not82 # copied it in, and nix believes it and goes looking for a path that is not
@@ -113,9 +137,19 @@ if [ -f /nix-cache/nix-cache-info ]; then
113 nix copy --no-check-sigs --all --to file:///nix-cache137 nix copy --no-check-sigs --all --to file:///nix-cache
114 fi138 fi
115 """139 """
116-env = { }140+# Nix's own cache, on the volume rather than in the container. Without it
141+# every Sandbox starts empty and `nix develop` re-clones the flake's git
142+# inputs -- jolt-src, jolt-native, glimmer, nixgl and their transitives --
143+# because flake.lock pins which revision to fetch, not whether it is already
144+# on disk. Set here rather than in the command so an interactive shell into
145+# this container gets it too.
146+env = { XDG_CACHE_HOME = "/devshell/frq-flutter-desktop/.cache" }
117 147
118 [nix]148 [nix]
149+# Every nix command in the container reads the mounted cache, including one
150+# typed by hand in a shell. Passing --extra-substituters per command only ever
151+# covered the scripts.
152+substituters = ["file:///nix-cache"]
119 # No devShell warming at image build time: this enters `nix develop` at run153 # No devShell warming at image build time: this enters `nix develop` at run
120 # time, on the VM, where the cache answers for its closure. The ptyshim that154 # time, on the VM, where the cache answers for its closure. The ptyshim that
121 # warming would need under gVisor is deprecated and does not come back.155 # warming would need under gVisor is deprecated and does not come back.
modified .modal/frq/container.toml +12 -0
@@ -12,6 +12,14 @@ runtime = "sandbox"
1212 # the point of copying rather than fetching.
1313 context = "../.."
1414 include = ["."]
15+# The build state a local checkout carries: 395MB of a 441MB repo, uploaded on
16+# every start and wanted by nothing out there. Flutter builds into a volume of
17+# its own, and the jolt and clojure caches are this machine's.
18+ignore = [
19+ "flutter/build", "flutter/.home", "flutter/.dart_tool",
20+ "flutter/.clojuredart", "flutter/.cpcache",
21+ ".jolt", ".cpcache", "result", "build", ".git",
22+]
1523
1624 # The Modal Volume that makes a second build cheap. Mounted at run time, which
1725 # is when the build happens here -- nothing is written to it while the image is
@@ -95,6 +103,10 @@ fi
95103 env = { }
96104
97105 [nix]
106+# Every nix command in the container reads the mounted cache, including one
107+# typed by hand in a shell. Passing --extra-substituters per command only ever
108+# covered the scripts.
109+substituters = ["file:///nix-cache"]
98110 # No devShell: we want `nix build`, not a shell to run something inside, so
99111 # there is nothing to warm at build time and no reason for the ptyshim. The
100112 # build happens at run time, in the Sandbox, on a real VM -- which is the
@@ -12,6 +12,14 @@ runtime = "sandbox"
12 # the point of copying rather than fetching.12 # the point of copying rather than fetching.
13 context = "../.."13 context = "../.."
14 include = ["."]14 include = ["."]
15+# The build state a local checkout carries: 395MB of a 441MB repo, uploaded on
16+# every start and wanted by nothing out there. Flutter builds into a volume of
17+# its own, and the jolt and clojure caches are this machine's.
18+ignore = [
19+ "flutter/build", "flutter/.home", "flutter/.dart_tool",
20+ "flutter/.clojuredart", "flutter/.cpcache",
21+ ".jolt", ".cpcache", "result", "build", ".git",
22+]
15 23
16 # The Modal Volume that makes a second build cheap. Mounted at run time, which24 # The Modal Volume that makes a second build cheap. Mounted at run time, which
17 # is when the build happens here -- nothing is written to it while the image is25 # is when the build happens here -- nothing is written to it while the image is
@@ -95,6 +103,10 @@ fi
95 env = { }103 env = { }
96 104
97 [nix]105 [nix]
106+# Every nix command in the container reads the mounted cache, including one
107+# typed by hand in a shell. Passing --extra-substituters per command only ever
108+# covered the scripts.
109+substituters = ["file:///nix-cache"]
98 # No devShell: we want `nix build`, not a shell to run something inside, so110 # No devShell: we want `nix build`, not a shell to run something inside, so
99 # there is nothing to warm at build time and no reason for the ptyshim. The111 # there is nothing to warm at build time and no reason for the ptyshim. The
100 # build happens at run time, in the Sandbox, on a real VM -- which is the112 # build happens at run time, in the Sandbox, on a real VM -- which is the