1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
|
[container]
name = "frq-flutter-web"
description = "the Flutter web build, incremental, in a nix devShell"
base = "arch-nix"
# A Sandbox, not a Function: it runs on a real VM, the command is the
# sandbox's own process so it dies when the command does, and only a Sandbox
# can hold open a tunnel -- which is the whole of `serve`.
runtime = "sandbox"
[build]
# The container lives inside the repo it builds, so the copy is rooted two
# levels up and `.` is the whole tree.
context = "../.."
include = ["."]
# The devShell, baked in rather than entered -- `flutter-dev`'s trick and for
# its reasons. `print-dev-env` writes the whole environment out as shell and
# realises its inputs on the way, so the closure becomes an image layer
# instead of a fetch every container pays for; sourcing it from .bashrc means
# a shell attached to this container *is* the devShell.
#
# `dev` stays for the case where the baked env is stale against a flake edit.
# The toolchain layer, and what it is allowed to depend on.
#
# `warm` is copied before `setup` runs and is deliberately six files: the
# flake and its lock, plus the four `cljd-deps` actually reads -- it does
# `cp ${./common/deps.edn}`, `${./flutter/deps.edn}`, `${./flutter/pubspec.yaml}`
# and `${./flutter/pubspec.lock}` and nothing else. That is the whole of what
# `nix develop .#flutter-web` needs to evaluate, so this layer moves when a
# dependency moves and not when a line of ClojureDart does.
#
# The point of the split: `[build] commands` run after the full source copy,
# so editing `flutter/src/frq/net/web.cljd` used to invalidate them and spend
# minutes re-warming a devShell that had not changed. Here the image is built
# from the toolchain and the program is built in the sandbox, which is where
# it was always going to happen anyway.
warm = [
"flake.nix", "flake.lock",
"common/deps.edn",
"flutter/deps.edn", "flutter/pubspec.yaml", "flutter/pubspec.lock",
]
# The devShell, baked in rather than entered. `print-dev-env` writes the whole
# environment out as shell and realises its inputs on the way, so the closure
# becomes an image layer instead of a fetch every container pays for; sourcing
# it from .bashrc means a shell attached to this container *is* the devShell.
#
# Best-effort, and the reason is gVisor. An image build is a Function
# underneath, so it cannot *build* a derivation -- and `print-dev-env` realises
# the shell's inputs, which here includes `flutter-wrapped-…-sdk-links.drv`,
# in no binary cache and therefore built. Under gVisor that dies with
# `unexpected EOF reading a line`, and the ptyshim that papered over it is
# deprecated. Nothing here is load-bearing: [run] enters `nix develop` itself,
# on the VM, where a real pty makes the same build work.
setup = [
"nix print-dev-env /app#flutter-web --accept-flake-config --extra-substituters file:///nix-cache > /etc/devshell.sh || rm -f /etc/devshell.sh",
"echo '[ -s /etc/devshell.sh ] && . /etc/devshell.sh' >> /root/.bashrc",
"printf '#!/bin/sh\\nexec nix develop /app#flutter-web \"$@\"\\n' > /usr/local/bin/dev && chmod +x /usr/local/bin/dev",
]
# The build state a local checkout carries, wanted by nothing out here: this
# container builds into a volume of its own, and the jolt and clojure caches
# are the laptop's.
ignore = [
"flutter/build", "flutter/.home", "flutter/.dart_tool",
"flutter/.clojuredart", "flutter/.cpcache",
".jolt", ".cpcache", "result", "build", ".git",
# An editor's linter rewrites this while the upload is reading it, and
# Modal fails the whole run with "was modified during build process".
# Nothing out here reads it.
".clj-kondo",
]
# `nix-cache` is the binary cache every container here reads from and writes
# back to. `devshell` is the working state of a `nix develop` loop, shared by
# every container that has one -- each gets its own directory under it, named
# for the devShell it belongs to, so `flutter-web` and `flutter-desktop` never
# write the same tree even though they share a `.home`-shaped cache layout.
# Modal Volumes have no locking, so those directory names are the only thing
# keeping them apart, and two runs of the *same* devshell must not overlap.
[volumes]
nix-cache = "/nix-cache"
devshell = "/devshell"
[resources]
cpu = 8
memory = 16384
timeout = 3600
# The port `just flutter-web serve` listens on, tunnelled out. Nothing is
# served unless the command asks for it -- a plain build exits and the tunnel
# closes with the sandbox -- but the port has to be declared at create time,
# so it is declared once here and `modal run --command` decides whether
# anything ever binds it.
[network]
ports = [8080]
[run]
workdir = "/app"
# Nix for the dependencies, the ordinary toolchain for the build -- the
# `flutter-dev` argument, unchanged: a derivation is all-or-nothing, so any
# edit under `nix build` is a fresh sandbox and a fresh compile of everything.
# Here the devShell supplies dart2js and the engine artifacts and `flutter
# build web` decides what is stale.
command = """
set -e
# This container's own directory on the shared devshell volume, named for the
# devShell whose state it keeps. `flutter-desktop` has its own beside it.
SHELL_DIR=/devshell/frq-flutter-web
mkdir -p "$SHELL_DIR" "$SHELL_DIR/.cache"
# A worktree's `.git` is a *file* naming a gitdir back on the machine that
# copied it in, and nix believes it and goes looking for a path that is not
# here. It has to go before any flake reference to /app.
rm -rf /app/.git
echo "sync: /app -> $SHELL_DIR"
# `nix shell --command` and not `nix profile install`: a profile install puts
# rsync in ~/.nix-profile/bin, which is not on the PATH of the shell already
# running.
#
# rsync and not cp, with --checksum and not mtimes: Modal copies the source in
# with fresh timestamps every run, so a plain copy looks entirely new to
# Flutter and rebuilds the lot. --checksum compares content and leaves the
# unchanged files' timestamps alone, which is the whole basis of the
# incremental build.
#
# The excludes are the state we are here to keep -- overwriting them from /app
# would defeat the volume. `flutter/web/` is NOT on the list: it is committed
# now, because the OAuth client keeps a script there, so it has to arrive from
# /app like any other source.
nix shell nixpkgs#rsync --accept-flake-config \
--extra-substituters file:///nix-cache --command \
rsync -a --checksum --delete \
--exclude 'flutter/.home/' \
--exclude 'flutter/.clojuredart/' \
--exclude 'flutter/build/' \
--exclude 'flutter/.dart_tool/' \
--exclude '.git' \
/app/ "$SHELL_DIR/"
cd "$SHELL_DIR"
echo "state carried over:"
du -sh flutter/.home flutter/.clojuredart flutter/build 2>/dev/null \
|| echo " (none yet -- first run)"
# Evaluated from /app and built in the volume, as `flutter-dev` does and for
# the same two reasons: /app is the pristine copy, so nix stores a source tree
# of the repo rather than one carrying gigabytes of flutter/build, while the
# recipe still runs where the state it reuses lives. `just -f` is what puts it
# there, since the recipe cds to its own justfile's directory.
# The baked devShell if there is one, and `nix develop` if there is not.
#
# This is the difference between a two-minute rebuild and a three-minute one.
# `nix develop /app#flutter-web` re-copies the whole repo into the nix store
# and re-evaluates the flake on every run -- the flake's source is the tree,
# so any edit makes it a new source -- and all of that to arrive at an
# environment the image already computed with `print-dev-env` and wrote to
# /etc/devshell.sh. Sourcing it is the same PATH and the same variables with
# no evaluation at all.
#
# The fallback is not decoration: that setup step is best-effort, because
# realising the devShell under gVisor can fail (see [build] setup), and a
# container whose bake did not happen still has to build.
# The devShell environment, computed once and kept on the volume.
#
# `nix develop /app#flutter-web` re-copies the whole repo into the nix store
# and re-evaluates the flake on every run -- the flake's source IS the tree,
# so any edit makes it a new source -- to arrive at an environment that has
# not changed. `print-dev-env` writes that environment out as shell, and
# sourcing it is the same PATH and the same variables with no evaluation.
#
# Here and not in [build] setup, which is where it used to be: an image build
# is a Function under gVisor, where nix cannot realise a derivation, and this
# devShell's closure contains one no cache can answer for
# (flutter-wrapped-...-sdk-links). That bake failed every time and was made
# non-fatal, which meant it silently never happened. On the VM it works, and
# the volume is what makes it worth doing once.
#
# Regenerated when flake.lock's CONTENT changes -- by hash and never by mtime.
# Modal copies /app in with fresh timestamps on every run, so `-nt` says the
# lock is newer every single time and the cache never hits. That is the same
# trap the rsync above documents and works around with --checksum; it catches
# anything here that asks a file when it changed.
# Beside the working tree and NOT inside it: the rsync above runs with
# --delete, so anything under $SHELL_DIR that is not in /app is removed on
# every run. Kept in there, this cache was deleted moments before it was
# consulted, which is why it recomputed every time while claiming to be a
# cache.
ENV_DIR="$SHELL_DIR.env"
mkdir -p "$ENV_DIR"
ENV_SH="$ENV_DIR/devshell.sh"
STAMP="$ENV_DIR/devshell.lock"
WANT="$(sha256sum /app/flake.lock | cut -d' ' -f1)"
if [ ! -s "$ENV_SH" ] || [ "$(cat "$STAMP" 2>/dev/null)" != "$WANT" ]; then
echo "devshell: computing (first run, or flake.lock changed)"
nix print-dev-env /app#flutter-web --accept-flake-config \
--extra-substituters file:///nix-cache > "$ENV_SH.tmp"
mv "$ENV_SH.tmp" "$ENV_SH"
echo "$WANT" > "$STAMP"
else
echo "devshell: reusing the computed env"
fi
# A subshell, so what the env sets does not leak into the `nix copy` below --
# that wants the container's own nix, not the shell's. `set +u` because a
# printed dev env references variables that need not be set.
( set +u; . "$ENV_SH"; set -u
just -f "$SHELL_DIR/justfile" flutter-web )
echo "built:"
du -sh flutter/build/web
# The devShell's closure is gigabytes of Flutter and Dart, and the store it
# landed in belongs to the image rather than to a volume -- so without this
# every run re-fetches it from upstream. Written back, the next run
# substitutes it from file:///nix-cache instead.
if [ -f /nix-cache/nix-cache-info ]; then
echo "cache: writing the devShell closure back"
nix copy --no-check-sigs --all --to file:///nix-cache
fi
"""
# Nix's own cache, on the volume rather than in the container. Without it
# every Sandbox starts empty and `nix develop` re-clones the flake's git
# inputs, because flake.lock pins which revision to fetch and not whether it
# is already on disk. Set here rather than in the command so an interactive
# shell into this container gets it too.
env = { XDG_CACHE_HOME = "/devshell/frq-flutter-web/.cache" }
[nix]
# Every nix command in the container reads the mounted cache, including one
# typed by hand in a shell.
substituters = ["file:///nix-cache"]
# No devShell warming at image build time: this enters `nix develop` at run
# time, on the VM, where the cache answers for its closure. The ptyshim that
# warming would need under gVisor is deprecated and does not come back.
flake = false
shim = false
# [experimental] overrides the sandbox default of vm_runtime = true.
|