claude-bulkhead 0.0.0-stage → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/LICENSE +21 -0
  3. package/README.es.md +498 -0
  4. package/README.ja.md +498 -0
  5. package/README.ko.md +498 -0
  6. package/README.md +497 -2
  7. package/README.pt-BR.md +498 -0
  8. package/README.zh-CN.md +498 -0
  9. package/bin/bulkhead +3 -0
  10. package/bin/bulkhead-slot +3 -0
  11. package/bin/bulkhead-statusline +3 -0
  12. package/hooks/hooks.json +39 -0
  13. package/package.json +52 -4
  14. package/src/basics.js +236 -0
  15. package/src/checkout-guard.js +296 -0
  16. package/src/cli-util.js +79 -0
  17. package/src/cli.js +41 -0
  18. package/src/config-check.js +29 -0
  19. package/src/config-cli.js +51 -0
  20. package/src/config.js +236 -0
  21. package/src/dev-servers.js +223 -0
  22. package/src/disk-guard.js +200 -0
  23. package/src/doctor.js +255 -0
  24. package/src/events.js +108 -0
  25. package/src/hook.js +94 -0
  26. package/src/kill-guard.js +435 -0
  27. package/src/log-cli.js +146 -0
  28. package/src/memory-guard.js +145 -0
  29. package/src/memory.js +76 -0
  30. package/src/orphan-reaper.js +527 -0
  31. package/src/paths.js +31 -0
  32. package/src/port-cli.js +59 -0
  33. package/src/port-guard.js +142 -0
  34. package/src/processes.js +181 -0
  35. package/src/reap-cli.js +56 -0
  36. package/src/remote-sh.js +478 -0
  37. package/src/remote.js +1034 -0
  38. package/src/remotes.js +126 -0
  39. package/src/scan.js +604 -0
  40. package/src/sessions.js +147 -0
  41. package/src/shell.js +597 -0
  42. package/src/slot-cli.js +150 -0
  43. package/src/slots.js +168 -0
  44. package/src/status.js +111 -0
  45. package/src/statusline.js +69 -0
  46. package/src/top-worker.js +14 -0
  47. package/src/top.js +305 -0
  48. package/src/wait-guard.js +263 -0
@@ -0,0 +1,478 @@
1
+ 'use strict'
2
+ // The bash scripts bulkhead remote runs on the other machine. Each one is sent on ssh's stdin to
3
+ // `<shell> -s`, so the remote needs only bash, git and tar, and it works whatever the login shell
4
+ // is (bash, zsh or Windows cmd). Values go in single-quoted, and every script is built in memory:
5
+ // a script read from disk while another process edits it once ran half old, half new.
6
+
7
+ const q = s => `'${String(s).replace(/'/g, `'\\''`)}'`
8
+
9
+ // Ports: 100 blocks of 8 from 7100. 5000 (macOS AirPlay) and 5060/5061 (refused by browsers)
10
+ // are below the range. A project keeps its block in <dir>/ports/<n>, so two projects never share one.
11
+ const PORT_BASE = 7100
12
+ const PORT_BLOCKS = 100
13
+ const LETTERS = 'abcdefgh'
14
+
15
+ // FNV-1a. Only a starting point for the block search on the remote, so any stable hash will do.
16
+ function hash(s) {
17
+ let h = 0x811c9dc5
18
+ for (const b of Buffer.from(String(s))) h = Math.imul(h ^ b, 0x01000193) >>> 0
19
+ return h
20
+ }
21
+
22
+ // The whole script is one { } group. bash reads a script from a pipe as it goes, so without the
23
+ // group a later `read` (the disconnect watcher) would consume the rest of the script as input.
24
+ const group = body => `{\n${body}}\n`
25
+
26
+ // Shared by every script: where the remote dir is, how to talk back, and the helpers.
27
+ // Lines for bulkhead go out as `@@bulkhead-<token> key value`, so nothing a job or a login
28
+ // script prints can be mistaken for them.
29
+ function prelude({ token, dir, markFd = 2 }) {
30
+ return `set -u
31
+ T=${q(token)}
32
+ D=${q(dir)}
33
+ MARKFD=${markFd}
34
+ mark() { printf '@@bulkhead-%s %s\\n' "$T" "$*" >&"$MARKFD"; }
35
+ WIN=0
36
+ case "$(uname -s 2>/dev/null)" in MINGW*|MSYS*|CYGWIN*) WIN=1 ;; esac
37
+ case "$D" in '~') D=$HOME ;; '~/'*) D=$HOME/\${D#'~/'} ;; esac
38
+ # Git Bash: C:/x and C:\\x become /c/x. GNU tar reads "C:" in a file name as a remote host.
39
+ if [ "$WIN" = 1 ]; then case "$D" in [A-Za-z]:*) D=$(cygpath -u "$D") ;; esac; fi
40
+ native() { if [ "$WIN" = 1 ]; then cygpath -m "$1"; else printf '%s\\n' "$1"; fi; }
41
+ now() { date +%s; }
42
+ gb() { awk -v m="$1" 'BEGIN { printf "%.1f", m / 1024 }'; }
43
+ alive() { kill -0 "$1" 2>/dev/null || ps -p "$1" >/dev/null 2>&1; }
44
+ # macOS prints "{ sec = 1700000000, usec = 52000 } Tue Nov 14 ...", and "usec = " ends in "sec = " too
45
+ kern_boottime() { sed -n 's/^[^{]*{ *sec = \\([0-9][0-9]*\\).*$/\\1/p'; }
46
+ boot_time() {
47
+ b=$(awk '/^btime/ { print $2 }' /proc/stat 2>/dev/null)
48
+ [ -z "$b" ] && b=$(sysctl -n kern.boottime 2>/dev/null | kern_boottime)
49
+ [ -z "$b" ] && [ -r /proc/uptime ] && b=$(( $(now) - $(cut -d. -f1 /proc/uptime) ))
50
+ printf '%s\\n' "\${b:-0}"
51
+ }
52
+ # "free total" in MB. Git Bash has /proc/meminfo too; its MemFree is Windows' available memory.
53
+ mem_mb() {
54
+ if [ -r /proc/meminfo ]; then
55
+ awk '/^MemTotal:/ { t = $2 } /^MemAvailable:/ { a = $2 } /^MemFree:/ { f = $2 }
56
+ END { if (a == "") a = f; if (t) printf "%d %d\\n", a / 1024, t / 1024 }' /proc/meminfo
57
+ elif command -v vm_stat >/dev/null 2>&1; then
58
+ vm_stat | awk -v ps="$(sysctl -n hw.pagesize 2>/dev/null || echo 4096)" -v tot="$(sysctl -n hw.memsize 2>/dev/null || echo 0)" '
59
+ /^Pages free/ { f = $3 + 0 } /^Pages inactive/ { i = $3 + 0 } /^Pages speculative/ { s = $3 + 0 } /^Pages purgeable/ { p = $3 + 0 }
60
+ END { printf "%d %d\\n", (f + i + s + p) * ps / 1048576, tot / 1048576 }'
61
+ elif [ "$WIN" = 1 ]; then
62
+ powershell -NoProfile -Command '$o = Get-CimInstance Win32_OperatingSystem; "$([int]($o.FreePhysicalMemory / 1024)) $([int]($o.TotalVisibleMemorySize / 1024))"' 2>/dev/null | tr -d '\\r'
63
+ fi
64
+ }
65
+ lock_info() { sed -n "s/^$2=//p" "$1/info" 2>/dev/null | head -n 1; }
66
+ lock_started() {
67
+ local s
68
+ s=$(lock_info "$1" started)
69
+ [ -z "$s" ] && s=$(stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || now)
70
+ printf '%s\\n' "$s"
71
+ }
72
+ # Seconds since the holder last wrote its heartbeat (once a minute while the job runs).
73
+ lock_quiet() {
74
+ local b
75
+ b=$(cat "$1/beat" 2>/dev/null)
76
+ [ -z "$b" ] && b=$(lock_started "$1")
77
+ echo $(( $(now) - b ))
78
+ }
79
+ # A held lock: not one being removed (.stale.<pid>) or a reclaim mutex (.reclaim).
80
+ is_lock() { [ -d "$1" ] && case "$1" in *.stale.*|*.reclaim) return 1 ;; esac; }
81
+ # Prints why a lock is stale and returns 0, or returns 1 while its holder may still be running.
82
+ lock_stale() {
83
+ local p
84
+ p=$(lock_info "$1" pid)
85
+ if [ -n "$p" ] && ! alive "$p"; then echo "its process $p is gone"; return 0; fi
86
+ [ "$BOOT" -gt 0 ] && [ "$(lock_started "$1")" -lt "$BOOT" ] && { echo "it was taken before the last reboot"; return 0; }
87
+ [ "$(lock_quiet "$1")" -gt 21600 ] && { echo "no sign of life for 6 hours"; return 0; }
88
+ return 1
89
+ }
90
+ BOOT=$(boot_time)
91
+ `
92
+ }
93
+
94
+ // Answers whether the remote is usable and which commits copy a already has.
95
+ function probeScript({ token, dir, project, sha }) {
96
+ return group(prelude({ token, dir, markFd: 1 }) + `P=${q(project)}
97
+ SHA=${q(sha || '')}
98
+ A="$D/$P"
99
+ mark ok
100
+ mark platform "$(uname -s 2>/dev/null)"
101
+ mark bash "$BASH_VERSION"
102
+ # macOS without the command line tools has a /usr/bin/git that only offers to install them
103
+ v=$(git --version 2>/dev/null) && [ -n "$v" ] && mark git "$v" || mark git none
104
+ if command -v tar >/dev/null 2>&1; then mark tar yes; else mark tar none; fi
105
+ mark dir "$(native "$D")"
106
+ if mkdir -p "$D" 2>/dev/null && : > "$D/.probe-$$" 2>/dev/null; then rm -f "$D/.probe-$$"; mark writable yes; else mark writable no; fi
107
+ mark mem "$(mem_mb)"
108
+ if [ -n "$SHA" ] && [ -d "$A/.git" ]; then
109
+ if git -C "$A" cat-file -e "$SHA^{commit}" 2>/dev/null; then mark head yes; else
110
+ mark head no
111
+ { git -C "$A" for-each-ref --format='%(objectname)' refs/bulkhead/ 2>/dev/null
112
+ git -C "$A" worktree list --porcelain 2>/dev/null | sed -n 's/^HEAD //p'; } | sort -u | while read -r r; do mark ref "$r"; done
113
+ fi
114
+ fi
115
+ `)
116
+ }
117
+
118
+ // Saves the upload (a tar of the bundle and the changed files) under <dir>/.incoming/<id>.
119
+ // tar reads the rest of stdin: bash reads its script one byte at a time from a pipe, so the
120
+ // bytes after the last line are left for the command on it.
121
+ function uploadScript({ token, dir, id }) {
122
+ return group(prelude({ token, dir }) + `U="$D/.incoming/"${q(id)}
123
+ mkdir -p "$U" || exit 4
124
+ exec tar -xf - -C "$U"
125
+ `)
126
+ }
127
+
128
+ // Sends one path from a copy as a tar stream on stdout, after a marker line so that anything a
129
+ // login script prints first can be skipped.
130
+ function fetchScript({ token, dir, project, letter, sub, path }) {
131
+ return group(prelude({ token, dir }) + `P=${q(project)}
132
+ L=${q(letter)}
133
+ R=${q(path)}
134
+ if [ "$L" = a ]; then C="$D/$P"; else C="$D/$P-$L"; fi
135
+ [ -d "$C" ] || { mark fail "copy $L of $P does not exist on this machine"; exit 4; }
136
+ cd "$C"/${q(sub || '.')} 2>/dev/null || { mark fail "no folder ${sub || '.'} in copy $L"; exit 4; }
137
+ [ -e "$R" ] || { mark fail "$R does not exist in copy $L ($(native "$PWD"))"; exit 1; }
138
+ printf '@@bulkhead-%s tar\n' "$T"
139
+ # "./" first, so a name starting with - is not read as an option
140
+ exec tar -cf - -C "$(dirname "./$R")" "./$(basename "./$R")"
141
+ `)
142
+ }
143
+
144
+ // One line per held lock and per copy, and free RAM.
145
+ function statusScript({ token, dir }) {
146
+ return group(prelude({ token, dir, markFd: 1 }) + `mark ok
147
+ mark dir "$(native "$D")"
148
+ mark mem "$(mem_mb)"
149
+ for l in "$D"/jobs/job-* "$D"/lock-*; do
150
+ is_lock "$l" || continue
151
+ why=$(lock_stale "$l") || why=""
152
+ mark lock "$(basename "$l")|$(( $(now) - $(lock_started "$l") ))|$(lock_info "$l" gb)|$(lock_info "$l" from)|$why|$(lock_info "$l" label)"
153
+ done
154
+ for c in "$D"/*; do [ -e "$c/.git" ] && mark copy "$(basename "$c")"; done
155
+ `)
156
+ }
157
+
158
+ // The job: take a job slot and a copy, bring the copy to the sent commit and changes, run the
159
+ // command with its own port, then stop whatever it left behind and free the locks.
160
+ function runScript(o) {
161
+ const L = LETTERS.slice(0, o.copies).split('').join(' ')
162
+ const lines = []
163
+ for (const f of o.deleted || []) lines.push(`rm -f -- "$C"/${q(f)}`)
164
+ for (const f of o.executable || []) lines.push(`chmod +x -- "$C"/${q(f)} 2>/dev/null`)
165
+ return group(prelude({ token: o.token, dir: o.dir }) + `P=${q(o.project)}
166
+ SHA=${q(o.sha)}
167
+ U=${q(o.upload || '')}
168
+ HAS_BUNDLE=${o.bundle ? 1 : 0}
169
+ HAS_TAR=${o.tar ? 1 : 0}
170
+ SUB=${q(o.sub || '.')}
171
+ CMD=${q(o.command)}
172
+ LABEL=${q(String(o.label || '').replace(/[\r\n]+/g, ' '))}
173
+ FROM=${q(o.from || '')}
174
+ GBMB=${Math.round((o.gb || 0) * 1024)}
175
+ MINFREE=${Math.round((o.minFreeGb || 0) * 1024)}
176
+ MAXJOBS=${o.maxJobs}
177
+ WAIT=${Math.max(0, Math.round(o.waitSeconds))}
178
+ POLL=${Math.max(1, Math.round(o.pollSeconds || 30))}
179
+ HOLD=${o.hold ? 1 : 0}
180
+ PHASH=${hash(o.project) % PORT_BLOCKS}
181
+ LETTERS=${q(L)}
182
+ N=${o.copies}
183
+ A="$D/$P"
184
+ INC="$D/.incoming"
185
+ JOBLOCK=""; COPYLOCK=""; RECLAIM=""; HB=""; W=""; JOB=""; DONE=0; L=""; C=""; FETCHED=0
186
+ trap '' PIPE
187
+ # The ssh channel stays on fd 0 and is read there: once bash has parsed the { } group it does not
188
+ # read fd 0 again. No copy on another fd: bash 3.2 (macOS) gives \`exec 3<&0\` a stream record
189
+ # sharing fd 0's input buffer, and a child that redirects fd 0 and then closes or redirects fd 3
190
+ # frees that buffer twice. macOS's allocator aborts on it (the job died of SIGABRT, exit 134).
191
+
192
+ release() { [ -n "$1" ] && [ -d "$1" ] && [ "$(lock_info "$1" pid)" = "$$" ] && rm -rf "$1"; }
193
+ # One process at a time may judge a lock stale and remove it. Without the .reclaim mutex, two
194
+ # waiters could both judge the same dead lock, and the slower one would then move away the
195
+ # fresh lock the faster one had just taken. A mutex left by a killed process expires in 2 min.
196
+ take() {
197
+ local why
198
+ if [ -d "$1" ]; then
199
+ [ -n "$(find "$1.reclaim" -maxdepth 0 -mmin +2 2>/dev/null)" ] && rmdir "$1.reclaim" 2>/dev/null
200
+ if mkdir "$1.reclaim" 2>/dev/null; then
201
+ RECLAIM="$1.reclaim"
202
+ if why=$(lock_stale "$1") && mv "$1" "$1.stale.$$" 2>/dev/null; then
203
+ rm -rf "$1.stale.$$"
204
+ mark reclaimed "$(basename "$1"): $why"
205
+ fi
206
+ rmdir "$RECLAIM" 2>/dev/null
207
+ RECLAIM=""
208
+ fi
209
+ fi
210
+ mkdir "$1" 2>/dev/null || return 1
211
+ printf 'pid=%s\\nstarted=%s\\ngb=%s\\nfrom=%s\\nlabel=%s\\n' "$$" "$(now)" "$GBMB" "$FROM" "$LABEL" > "$1/info"
212
+ }
213
+ take_job() {
214
+ local i=1
215
+ while [ "$i" -le "$MAXJOBS" ]; do
216
+ take "$D/jobs/job-$i" && { JOBLOCK="$D/jobs/job-$i"; return 0; }
217
+ i=$((i + 1))
218
+ done
219
+ return 1
220
+ }
221
+ copy_dir() { if [ "$1" = a ]; then echo "$D/$P"; else echo "$D/$P-$1"; fi; }
222
+ # Existing copies first: making a new one takes a checkout.
223
+ take_copy() {
224
+ local pass l c
225
+ for pass in old new; do
226
+ for l in $LETTERS; do
227
+ c=$(copy_dir "$l")
228
+ if [ "$pass" = old ]; then [ -e "$c/.git" ] || continue; else [ -e "$c/.git" ] && continue; fi
229
+ take "$D/lock-$P-$l" && { L=$l; C=$c; COPYLOCK="$D/lock-$P-$l"; return 0; }
230
+ done
231
+ done
232
+ return 1
233
+ }
234
+ # Jobs that started in the last 150 seconds count at their declared size: they have not peaked yet.
235
+ room() {
236
+ [ "$MINFREE" -eq 0 ] && [ "$GBMB" -eq 0 ] && return 0
237
+ local m free pend l a g
238
+ m=$(mem_mb)
239
+ [ -z "$m" ] && return 0
240
+ free=\${m%% *}
241
+ pend=0
242
+ for l in "$D"/jobs/job-*; do
243
+ is_lock "$l" && [ "$l" != "$JOBLOCK" ] || continue
244
+ a=$(lock_info "$l" started); g=$(lock_info "$l" gb)
245
+ [ -n "$a" ] && [ $(( $(now) - a )) -lt 150 ] && pend=$(( pend + \${g:-0} ))
246
+ done
247
+ [ $(( free - pend - GBMB )) -ge "$MINFREE" ] && return 0
248
+ WHY="free RAM $(gb "$free") GB"
249
+ [ "$pend" -gt 0 ] && WHY="$WHY - starting jobs $(gb "$pend") GB"
250
+ WHY="$WHY - this job $(gb "$GBMB") GB is below the $(gb "$MINFREE") GB minimum"
251
+ return 1
252
+ }
253
+ holders() {
254
+ local l
255
+ for l in "$D"/jobs/job-* "$D"/lock-"$P"-[a-h]; do
256
+ is_lock "$l" || continue
257
+ mark holder "$(basename "$l")|$(( $(now) - $(lock_started "$l") ))|$(lock_info "$l" from)|$(lock_info "$l" label)"
258
+ done
259
+ }
260
+ port_block() {
261
+ mkdir -p "$D/ports"
262
+ k=0
263
+ while [ "$k" -lt ${PORT_BLOCKS} ]; do
264
+ n=$(( (PHASH + k) % ${PORT_BLOCKS} ))
265
+ if mkdir "$D/ports/$n" 2>/dev/null; then printf '%s\\n' "$P" > "$D/ports/$n/project"; echo "$n"; return; fi
266
+ owner=$(cat "$D/ports/$n/project" 2>/dev/null)
267
+ [ -z "$owner" ] && { sleep 1; owner=$(cat "$D/ports/$n/project" 2>/dev/null); }
268
+ [ "$owner" = "$P" ] && { echo "$n"; return; }
269
+ k=$((k + 1))
270
+ done
271
+ # All taken: reuse a block whose project folder was deleted, one process at a time.
272
+ [ -n "$(find "$D/ports/.reclaim" -maxdepth 0 -mmin +2 2>/dev/null)" ] && rmdir "$D/ports/.reclaim" 2>/dev/null
273
+ if mkdir "$D/ports/.reclaim" 2>/dev/null; then
274
+ RECLAIM="$D/ports/.reclaim"
275
+ k=0
276
+ while [ "$k" -lt ${PORT_BLOCKS} ]; do
277
+ n=$(( (PHASH + k) % ${PORT_BLOCKS} ))
278
+ owner=$(cat "$D/ports/$n/project" 2>/dev/null)
279
+ if { [ -z "$owner" ] || [ ! -d "$D/$owner" ]; } && mv "$D/ports/$n" "$D/ports/.old-$n-$$" 2>/dev/null; then
280
+ rm -rf "$D/ports/.old-$n-$$"
281
+ if mkdir "$D/ports/$n" 2>/dev/null; then
282
+ printf '%s\\n' "$P" > "$D/ports/$n/project"
283
+ rmdir "$RECLAIM"; RECLAIM=""
284
+ echo "$n"; return
285
+ fi
286
+ fi
287
+ k=$((k + 1))
288
+ done
289
+ rmdir "$RECLAIM"; RECLAIM=""
290
+ fi
291
+ echo "$PHASH"
292
+ }
293
+ # PIDs listening on a TCP port. Windows: a listening socket's foreign address is 0.0.0.0:0 or
294
+ # [::]:0; the state column itself is translated on some systems.
295
+ port_pids() {
296
+ if [ "$WIN" = 1 ]; then
297
+ netstat -ano 2>/dev/null | tr -d '\\r' | awk -v p=":$1" '$1 == "TCP" && ($3 == "0.0.0.0:0" || $3 == "[::]:0") && substr($2, length($2) - length(p) + 1) == p { print $NF }' | sort -u
298
+ elif command -v lsof >/dev/null 2>&1; then
299
+ lsof -nP -t -iTCP:"$1" -sTCP:LISTEN 2>/dev/null | sort -u
300
+ elif command -v fuser >/dev/null 2>&1; then
301
+ fuser -n tcp "$1" 2>/dev/null | tr -s ' \\t' '\\n' | grep -E '^[0-9]+$' | sort -u
302
+ elif command -v ss >/dev/null 2>&1; then
303
+ ss -ltnpH "sport = :$1" 2>/dev/null | grep -o 'pid=[0-9]*' | cut -d= -f2 | sort -u
304
+ fi
305
+ }
306
+ stop_pid() {
307
+ if [ "$WIN" = 1 ]; then MSYS_NO_PATHCONV=1 taskkill /PID "$1" /T /F >/dev/null 2>&1; else kill -TERM "$1" 2>/dev/null; fi
308
+ }
309
+ # A server the command started and did not stop keeps the ssh session open until it exits;
310
+ # stop it by PID, sparing anything that listened on the port before the job.
311
+ free_port() {
312
+ hit=""
313
+ for p in $(port_pids "$PORT"); do
314
+ case " $PRE " in *" $p "*) continue ;; esac
315
+ stop_pid "$p"; hit="$hit $p"
316
+ done
317
+ [ -z "$hit" ] && return
318
+ mark portkill "$hit"
319
+ if [ "$WIN" = 0 ]; then sleep 1; for p in $hit; do kill -KILL "$p" 2>/dev/null; done; fi
320
+ }
321
+ # Everything in the job's process group, with each Windows process tree under it.
322
+ kill_group() {
323
+ kill -0 -- "-$1" 2>/dev/null || return 0
324
+ if [ "$WIN" = 1 ]; then
325
+ for w in $(ps | awk -v g="$1" '{ if ($1 !~ /^[0-9]+$/) { $1 = ""; $0 = $0 } if ($3 == g) print $4 }'); do
326
+ MSYS_NO_PATHCONV=1 taskkill /PID "$w" /T /F >/dev/null 2>&1
327
+ done
328
+ fi
329
+ kill -TERM -- "-$1" 2>/dev/null
330
+ sleep 1
331
+ kill -KILL -- "-$1" 2>/dev/null
332
+ return 0
333
+ }
334
+ cleanup() {
335
+ [ -n "$W" ] && kill "$W" 2>/dev/null
336
+ [ -n "$HB" ] && kill -- "-$HB" 2>/dev/null
337
+ [ -n "$JOB" ] && [ "$DONE" = 0 ] && kill_group "$JOB"
338
+ [ -n "$RECLAIM" ] && rmdir "$RECLAIM" 2>/dev/null
339
+ # a run that stopped between the fetch and the checkout leaves its incoming ref behind
340
+ [ "$FETCHED" = 1 ] && git -C "$A" update-ref -d "refs/bulkhead/in-$T" 2>/dev/null
341
+ release "$COPYLOCK"
342
+ release "$JOBLOCK"
343
+ [ -n "$U" ] && rm -rf "$INC/$U"
344
+ return 0
345
+ }
346
+ fail() { mark fail "$1"; mark exit "\${2:-4}"; exit "\${2:-4}"; }
347
+ trap cleanup EXIT
348
+ trap 'exit 129' HUP
349
+ trap 'exit 130' INT
350
+ trap 'exit 143' TERM
351
+
352
+ git --version >/dev/null 2>&1 || fail "git is not installed on this machine (or not on PATH for ssh sessions)"
353
+ mkdir -p "$D/jobs" || fail "cannot create $(native "$D")"
354
+ # uploads no run picked up, such as one whose sender was interrupted
355
+ [ -d "$INC" ] && find "$INC" -mindepth 1 -maxdepth 1 -mmin +1440 -exec rm -rf {} + 2>/dev/null
356
+
357
+ deadline=$(( $(now) + WAIT ))
358
+ told=0
359
+ while :; do
360
+ WHY=""
361
+ if take_job; then
362
+ if ! take_copy; then
363
+ if [ "$N" = 1 ]; then WHY="the only copy of $P is in use"; else WHY="all $N copies of $P are in use"; fi
364
+ elif ! room; then
365
+ release "$COPYLOCK"; COPYLOCK=""
366
+ fi
367
+ [ -n "$WHY" ] && { release "$JOBLOCK"; JOBLOCK=""; }
368
+ else
369
+ if [ "$MAXJOBS" = 1 ]; then WHY="the only job slot on this machine is taken"; else WHY="all $MAXJOBS job slots on this machine are taken"; fi
370
+ fi
371
+ [ -z "$WHY" ] && break
372
+ if [ "$(now)" -ge "$deadline" ]; then holders; mark noroom "$WHY"; mark exit 3; exit 3; fi
373
+ [ "$told" = 0 ] && { mark waiting "$WHY"; told=1; }
374
+ # waits like sleep, and notices when the client has gone away (EOF). bash 3.2 (macOS) returns 1
375
+ # for a timeout too, so there a quick return is what tells EOF apart.
376
+ t0=$(now)
377
+ read -r -t "$POLL" _
378
+ rc=$?
379
+ if [ "$rc" -eq 1 ]; then
380
+ [ "\${BASH_VERSINFO[0]}" -ge 4 ] && exit 0
381
+ [ $(( $(now) - t0 )) -lt $(( POLL - 1 )) ] && exit 0
382
+ fi
383
+ done
384
+ # in its own process group, so stopping it also stops the sleep it is in. It also ends by itself
385
+ # once this script is gone, such as after a kill -9 that skipped the cleanup.
386
+ set -m
387
+ ( while sleep 60 && kill -0 $$ 2>/dev/null; do now > "$JOBLOCK/beat"; now > "$COPYLOCK/beat"; done ) </dev/null >/dev/null 2>&1 &
388
+ HB=$!
389
+ set +m
390
+
391
+ # Copy a is a plain repository; the others are worktrees of it, so one fetch serves them all.
392
+ if [ ! -d "$A/.git" ]; then
393
+ k=0
394
+ until mkdir "$D/.init-$P" 2>/dev/null; do
395
+ k=$((k + 1)); [ "$k" -gt 60 ] && rm -rf "$D/.init-$P"; sleep 1
396
+ done
397
+ [ -d "$A/.git" ] || { mkdir -p "$A" && git init -q "$A"; } || { rmdir "$D/.init-$P"; fail "git init failed in $(native "$A")"; }
398
+ rmdir "$D/.init-$P"
399
+ fi
400
+ if [ "$HAS_BUNDLE" = 1 ]; then
401
+ FETCHED=1
402
+ git -C "$A" fetch -q "$INC/$U/bundle" "+HEAD:refs/bulkhead/in-$T" || fail "git fetch from the bundle failed"
403
+ fi
404
+ git -C "$A" cat-file -e "$SHA^{commit}" 2>/dev/null || fail "commit $SHA is not on this machine; run again to send it"
405
+ # A copy made from a copy a that has since been removed is a worktree of nothing: make it again.
406
+ # (rm does not follow the node_modules link inside it; on Windows, Git Bash treats a junction
407
+ # as a symlink too.)
408
+ if [ "$L" != a ] && [ -f "$C/.git" ] && ! git -C "$C" rev-parse --git-dir >/dev/null 2>&1; then
409
+ mark repaired "copy $L was a worktree of a removed copy a; made it again"
410
+ rm -rf "$C"
411
+ fi
412
+ if [ ! -e "$C/.git" ]; then
413
+ git -C "$A" worktree prune 2>/dev/null
414
+ [ -e "$C" ] && [ -n "$(ls -A "$C" 2>/dev/null)" ] && fail "$(native "$C") exists but is not a copy; move it away"
415
+ git -C "$A" worktree add -q --detach "$C" "$SHA" >/dev/null || fail "git worktree add failed for $(native "$C")"
416
+ fi
417
+ # Ignored files and node_modules stay, so the next run does not reinstall.
418
+ git -C "$C" checkout -q -f --detach "$SHA" || fail "git checkout of $SHA failed in $(native "$C")"
419
+ git -C "$C" clean -fdq -e node_modules || fail "git clean failed in $(native "$C")"
420
+ git -C "$A" update-ref "refs/bulkhead/$L" "$SHA"
421
+ git -C "$A" update-ref -d "refs/bulkhead/in-$T" 2>/dev/null
422
+ if [ "$HAS_TAR" = 1 ]; then
423
+ tar -xf "$INC/$U/changes.tar" -C "$C" || fail "unpacking the changed files failed"
424
+ fi
425
+ ${lines.join('\n')}
426
+ # Other copies use copy a's node_modules, so one install serves them all.
427
+ if [ "$L" != a ] && [ -d "$A" ]; then
428
+ (cd "$A" && find . -maxdepth 3 -name node_modules -type d -prune 2>/dev/null) | while read -r rel; do
429
+ rel=\${rel#./}
430
+ [ -e "$C/$rel" ] || [ -L "$C/$rel" ] && continue
431
+ [ -d "$(dirname "$C/$rel")" ] || continue
432
+ if [ "$WIN" = 1 ]; then
433
+ MSYS_NO_PATHCONV=1 cmd /c mklink /J "$(cygpath -w "$C/$rel")" "$(cygpath -w "$A/$rel")" >/dev/null 2>&1
434
+ else
435
+ ln -s "$A/$rel" "$C/$rel"
436
+ fi
437
+ done
438
+ fi
439
+ [ -n "$U" ] && rm -rf "$INC/$U"
440
+ cd "$C/$SUB" 2>/dev/null || fail "no folder $SUB in $(native "$C")"
441
+
442
+ i=0
443
+ for x in a b c d e f g h; do [ "$x" = "$L" ] && break; i=$((i + 1)); done
444
+ PORT=$(( ${PORT_BASE} + $(port_block) * 8 + i ))
445
+ export BULKHEAD_PORT="$PORT" BULKHEAD_COPY="$L"
446
+ PRE=$(port_pids "$PORT" | tr '\\n' ' ')
447
+ [ -n "$(echo $PRE)" ] && mark portbusy "$PRE"
448
+ mark start "$L $PORT $(native "$C")"
449
+ DISC="$COPYLOCK/disconnected"
450
+ # SIGPIPE back to the default the command would have anywhere else: ignored, "yes | head"
451
+ # would print errors and a pipeline under pipefail would fail. The ssh channel (fd 0) is not
452
+ # the command's to read.
453
+ set -m
454
+ ( trap - PIPE; exec "$BASH" -c "$CMD" ) </dev/null &
455
+ JOB=$!
456
+ set +m
457
+ # The watcher reads the ssh channel. EOF means the client is gone (Ctrl-C, a killed tool call,
458
+ # a dropped network), so the job is stopped instead of holding the copy for hours. 0<&0: without
459
+ # job control, bash gives a background command /dev/null for stdin unless it names one.
460
+ ( while read -r _; do :; done; : > "$DISC"; kill_group "$JOB" ) 0<&0 >/dev/null 2>&1 &
461
+ W=$!
462
+ wait "$JOB"
463
+ RC=$?
464
+ DONE=1
465
+ kill "$W" 2>/dev/null; W=""
466
+ kill_group "$JOB"
467
+ free_port
468
+ [ -f "$DISC" ] && exit "$RC"
469
+ mark exit "$RC"
470
+ if [ "$HOLD" = 1 ]; then
471
+ mark hold
472
+ while read -r _; do :; done
473
+ fi
474
+ exit "$RC"
475
+ `)
476
+ }
477
+
478
+ module.exports = { prelude, probeScript, uploadScript, fetchScript, statusScript, runScript, hash, q, PORT_BASE, PORT_BLOCKS, LETTERS }