lsp: speak all nine languages, not only Go

The table always carried the other entries; the image carried one toolchain, so
Available() answered false and the daemon served Go alone. Install the rest —
node with typescript-language-server, pyright and intelephense; the Rust
component tarballs with rust-src; clangd's own release; a JDK with jdtls; ruby
with ruby-lsp; composer for PHP — every version pinned, and add the java, php
and ruby entries beside them.

The three new entries keep the scripts-off bargain rather than bending it. PHP
fetches with composer's own --no-scripts --no-plugins. Java has two dependency
resolvers and both ARE build tools, so it has no fetch and answers from source
and the JDK. Ruby's bundle install compiles native extensions, so it is marked
Executes and refused, as python's is.

Three things that would otherwise be silent:

  * typescript-language-server prefers the TREE's node_modules/typescript —
    dependency-authored JavaScript, loaded and run in the serve phase, undoing
    what --ignore-scripts bought one phase earlier. Pin tsserver to the image's.
  * ruby-lsp creates .ruby-lsp/ in the project and shells out to bundle install
    unless BUNDLE_GEMFILE is set at all. On a read-only tree that is EACCES
    before the server starts.
  * Equinox takes its writable configuration area from the passwd entry, not
    $HOME, so jdtls would write to /home/lsp, which the jail does not bind.
    Name the area under the jail's own /tmp.

RLIMIT_NPROC is per UID and every jail shares it, so 256 became a way for a
JVM's forty threads to stop another language forking at all.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-08-09 11:48:26 -07:00
co-authored by Hanzo Dev
parent c6b2a14bf8
commit f66861921e
5 changed files with 337 additions and 28 deletions
+163 -9
View File
@@ -31,28 +31,55 @@ COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o /out/lsp .
# ── 2. The language servers ──────────────────────────────────────────────────
# ── 2. gopls ─────────────────────────────────────────────────────────────────
#
# PINNED, not @latest. A floating server version means two builds of the same
# commit answer differently, and the thing that changed is a binary nobody
# reviewed. Bumping is one line here.
#
# Phase 1 ships Go only. Adding a language is: install its server below, and
# internal/lsp/langs.go already carries the entry — Lang.Available() lights it up
# at runtime with no code change.
# The one server that is BUILT rather than downloaded, because the Go build
# stage is already here. Every other server arrives as its project's own
# release artifact in the runtime stage, beside the toolchain it needs.
FROM golang:1.26.5-bookworm AS tools
ARG GOPLS_VERSION=v0.23.0
RUN GOBIN=/out CGO_ENABLED=0 go install golang.org/x/tools/gopls@${GOPLS_VERSION}
# ── 3. Runtime ───────────────────────────────────────────────────────────────
#
# EVERY VERSION BELOW IS PINNED, and none of them is `latest`. A floating server
# means two builds of the same commit answer differently, and the thing that
# changed is a binary nobody reviewed. Bumping one is one line here.
#
# A language is SERVED when its server binary is on PATH — internal/lsp/langs.go
# carries the entry and Lang.Available() reads this image to decide whether the
# entry is true. So this file, and only this file, is what makes a language real.
#
# The image is large, and that is the honest cost of nine languages: a compiler
# or an interpreter per language, because a language server resolves definitions
# by type-checking source and cannot do it without one. It is pulled once per
# node and the daemon is long-lived.
FROM debian:bookworm-slim AS runtime
# ca-certificates for the FETCH phase's TLS to the module proxy; tini to reap the
# jail children (each root forks a language server, which forks `go list`).
# curl and unzip fetch and unpack the toolchains below.
#
# Then the half of the toolchain set that Debian versions better than we would:
#
# python3 pyright reads a tree's import search path out of a real
# interpreter; without one it sees only its bundled typeshed.
# ruby ruby-lsp is a gem, and the standard library it indexes is this
# interpreter's.
# php-* composer IS a PHP program, so the PHP fetch needs the interpreter
# even though the server itself is a Node one.
# g++ the C++ standard headers clangd resolves <vector> in, and the
# compiler a gem with a native extension builds with.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tini curl \
ca-certificates tini curl unzip \
python3 \
ruby ruby-dev \
php-cli php-curl php-mbstring php-zip \
g++ make \
&& rm -rf /var/lib/apt/lists/*
# ── Go ───────────────────────────────────────────────────────────────────────
#
# The Go toolchain, which gopls shells out to for `go list` and which the fetch
# phase runs as `go mod download`. A pinned tarball rather than apt: Debian's
# golang lags, and the toolchain version decides which repositories resolve —
@@ -69,6 +96,133 @@ RUN set -eux; \
rm /tmp/go.tgz
ENV PATH="/usr/local/go/bin:${PATH}"
# ── Node, and the three languages that ride on it ────────────────────────────
#
# One runtime, three servers: TypeScript/JavaScript, Python (pyright) and PHP
# (intelephense) are all Node programs. That is most of why this image can speak
# nine languages without being nine images.
#
# typescript is pinned to 5.x DELIBERATELY. TypeScript 7 is the native rewrite
# and ships no `bin/tsserver`; typescript-language-server drives tsserver, so
# the pair only exists at 5.x. When the server moves to the native protocol,
# both lines move together.
#
# --ignore-scripts on the global install, for the same reason the fetch phase
# uses it: a package's install hook is code nobody read. None of these four has
# one, which is what makes the flag free rather than brave.
ARG NODE_VERSION=24.19.0
ARG TYPESCRIPT_VERSION=5.9.3
ARG TYPESCRIPT_SERVER_VERSION=5.3.0
ARG PYRIGHT_VERSION=1.1.411
ARG INTELEPHENSE_VERSION=1.18.5
RUN set -eux; \
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.gz" -o /tmp/node.tgz; \
tar -C /usr/local --strip-components=1 --no-same-owner -xzf /tmp/node.tgz \
--exclude='*/CHANGELOG.md' --exclude='*/LICENSE' --exclude='*/README.md'; \
rm /tmp/node.tgz; \
npm install -g --ignore-scripts --no-audit --no-fund \
"typescript@${TYPESCRIPT_VERSION}" \
"typescript-language-server@${TYPESCRIPT_SERVER_VERSION}" \
"pyright@${PYRIGHT_VERSION}" \
"intelephense@${INTELEPHENSE_VERSION}"; \
npm cache clean --force
# ── Rust ─────────────────────────────────────────────────────────────────────
#
# The component tarballs, NOT rustup, and the difference is what is left in the
# image afterwards. rustup is a toolchain DOWNLOADER: with it installed, a
# tenant's rust-toolchain.toml is an instruction to go and get another compiler
# — an unbounded download in the phase that has a network, and a hang in the
# phase that does not. The real binaries under /usr/local read no toolchain file
# and can fetch nothing. It is the Rust spelling of GOTOOLCHAIN=local.
#
# rust-src is not an extra: rust-analyzer resolves the standard library from
# SOURCE, so without it every std symbol in every answer is unresolved.
ARG RUST_VERSION=1.97.1
RUN set -eux; \
for pkg in \
"rustc-${RUST_VERSION}-x86_64-unknown-linux-gnu" \
"cargo-${RUST_VERSION}-x86_64-unknown-linux-gnu" \
"rust-std-${RUST_VERSION}-x86_64-unknown-linux-gnu" \
"rust-analyzer-${RUST_VERSION}-x86_64-unknown-linux-gnu" \
"rust-src-${RUST_VERSION}" ; do \
curl -fsSL "https://static.rust-lang.org/dist/${pkg}.tar.gz" -o /tmp/rust.tgz; \
mkdir /tmp/rust; \
tar -C /tmp/rust --strip-components=1 --no-same-owner -xzf /tmp/rust.tgz; \
/tmp/rust/install.sh --prefix=/usr/local --disable-ldconfig; \
rm -rf /tmp/rust /tmp/rust.tgz; \
done
# ── C and C++ ────────────────────────────────────────────────────────────────
#
# clangd from the project's own release rather than apt: one pinned archive that
# carries its resource directory — the builtin headers it resolves <stddef.h>
# in — beside the binary, which it finds by resolving its own path. Hence the
# symlink into /usr/local/bin rather than a copy.
ARG CLANGD_VERSION=22.1.6
RUN set -eux; \
curl -fsSL "https://github.com/clangd/clangd/releases/download/${CLANGD_VERSION}/clangd-linux-${CLANGD_VERSION}.zip" -o /tmp/clangd.zip; \
unzip -q /tmp/clangd.zip -d /usr/local; \
mv "/usr/local/clangd_${CLANGD_VERSION}" /usr/local/clangd; \
chmod +x /usr/local/clangd/bin/clangd; \
ln -s /usr/local/clangd/bin/clangd /usr/local/bin/clangd; \
rm /tmp/clangd.zip
# ── Java ─────────────────────────────────────────────────────────────────────
#
# A JDK because jdtls IS a Java program, and a JDK rather than a JRE because it
# is also what jdtls reads the platform's own sources out of (lib/src.zip).
#
# The milestone, not the snapshot: `snapshots/latest.txt` moves, and a build
# that reads it is a build whose language server nobody chose. A milestone is a
# version plus the build stamp that identifies it, which together are immutable.
#
# `bin/jdtls` is the project's own Python launcher, and it is what langs.go
# starts rather than a hand-written `java -jar`: it already asks Equinox for a
# CASCADED configuration (`osgi.sharedConfiguration.area.readOnly=true`), which
# is the one thing an OSGi application needs to run from a read-only
# installation. Reproducing that argv here would be a second copy of it to keep
# right. It resolves its own path, so the symlink onto PATH is enough.
#
# `config_linux` is x86_64's, which is this image's — see the platform list in
# hanzo.yml. An arm64 image would need the launcher to pick config_linux_arm,
# and it does not.
ARG JDK_VERSION=21.0.12
ARG JDK_BUILD=8
ARG JDTLS_VERSION=1.60.0
ARG JDTLS_BUILD=202606262232
RUN set -eux; \
curl -fsSL "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-${JDK_VERSION}%2B${JDK_BUILD}/OpenJDK21U-jdk_x64_linux_hotspot_${JDK_VERSION}_${JDK_BUILD}.tar.gz" -o /tmp/jdk.tgz; \
mkdir -p /usr/local/jdk; \
tar -C /usr/local/jdk --strip-components=1 --no-same-owner -xzf /tmp/jdk.tgz; \
rm /tmp/jdk.tgz; \
ln -s /usr/local/jdk/bin/java /usr/local/bin/java; \
curl -fsSL "https://download.eclipse.org/jdtls/milestones/${JDTLS_VERSION}/jdt-language-server-${JDTLS_VERSION}-${JDTLS_BUILD}.tar.gz" -o /tmp/jdtls.tgz; \
mkdir -p /usr/local/jdtls; \
tar -C /usr/local/jdtls --no-same-owner -xzf /tmp/jdtls.tgz; \
rm /tmp/jdtls.tgz; \
ln -s /usr/local/jdtls/bin/jdtls /usr/local/bin/jdtls
# ── PHP ──────────────────────────────────────────────────────────────────────
#
# composer, for the fetch phase only: `--no-scripts --no-plugins` is composer's
# own switch for "place vendor/, run none of its authors' code", the exact shape
# of npm's --ignore-scripts. The server itself is intelephense, installed with
# Node above.
ARG COMPOSER_VERSION=2.10.2
RUN set -eux; \
curl -fsSL "https://getcomposer.org/download/${COMPOSER_VERSION}/composer.phar" -o /usr/local/bin/composer; \
chmod +x /usr/local/bin/composer
# ── Ruby ─────────────────────────────────────────────────────────────────────
#
# Installed into Debian's own gem directory (/var/lib/gems) rather than a private
# one, so RubyGems finds it with no GEM_HOME in the jail's built environment —
# the bind in internal/lsp/root.go is the whole of the wiring. The executable
# lands in /usr/local/bin, which is already on the jail's PATH.
ARG RUBY_LSP_VERSION=0.26.10
RUN gem install ruby-lsp --version "${RUBY_LSP_VERSION}" --no-document
COPY --from=tools /out/gopls /usr/local/bin/gopls
COPY --from=build /out/lsp /usr/local/bin/lsp
+31 -12
View File
@@ -200,21 +200,36 @@ server did not answer; `503` sandbox unavailable.
### `GET /healthz` · `GET /readyz`
Liveness answers 200 while the process is up. Readiness answers what the jail
decided: `{ "ready": true, "jail": "jailed", "langs": ["go"] }`, or 503 with the
reason. They are different questions and do not collapse into one.
decided: `{ "ready": true, "jail": "jailed", "langs": [ … ] }`, or 503 with the
reason. `langs` is the table narrowed to the servers this image actually has, so
it is also the honest answer to "what can you speak". They are different
questions and do not collapse into one.
## Languages
`internal/lsp/langs.go` carries five complete entries — `go`, `rust`,
`typescript`, `python`, `cpp` — with argv, root markers, extensions, cache and
fetch. What decides whether one is SERVED is `Lang.Available()`: whether its
binary is on PATH in the image.
**Phase 1 ships the Go toolchain and gopls, so Go is the only language served.**
The other four are inert in this image and light up the moment their server is
installed in the Dockerfile — no code change. That is deliberate: the table never
`internal/lsp/langs.go` carries one entry per language — argv, root markers,
extensions, cache and fetch. What decides whether one is SERVED is
`Lang.Available()`: whether its binary is on PATH in the image. The table never
promises what the deployment cannot do.
| | server | fetch | resolves dependencies from |
|---|---|---|---|
| `go` | gopls | `go mod download all` | the module cache |
| `typescript` (`.ts` `.tsx` `.js` `.jsx`) | typescript-language-server + tsserver | `npm ci --ignore-scripts` | `node_modules` in the tree |
| `php` | intelephense | `composer install --no-scripts --no-plugins` | `vendor/` in the tree |
| `rust` | rust-analyzer | `cargo fetch --locked` | the cargo registry cache |
| `python` | pyright | none — `uv sync` builds sdists | source + bundled typeshed |
| `ruby` | ruby-lsp | none — `bundle install` compiles | source + the `rbs` core signatures |
| `java` | jdtls | none — every Java resolver is a build tool | source + the JDK |
| `cpp` (`.c` `.h` too) | clangd | none — no resolver exists | `compile_commands.json`, else one file |
The four with no fetch still answer about the tree's own source and its
platform's; what they lose is a definition that crosses into a third-party
package. That is the scripts-off trade, taken deliberately: see `Fetchable`.
A server that fails to start takes only its own language down — `build()` logs
`language server did not start` and the rest of the tree keeps answering.
## Bounds
| | |
@@ -371,5 +386,9 @@ BINARY and a self-exec'd test suite proves nothing.
3. **MCP.** EXTEND the existing single `lsp` tool (`mcp/rust/src/tools/lsp_tool.rs`,
`python-sdk/pkg/hanzo-tools-lsp`) with `repo`/`rev` parameters — a file goes
to the local stdio server, a repo goes to `/v1/code/lsp/*`. Not a new tool.
4. **More languages** = install the server in the Dockerfile. The table entries
already exist.
4. **More languages** = install the server in the Dockerfile and add its entry.
DONE for the nine above. Two of them are worth watching: rust-analyzer runs
`cargo metadata` against a READ-ONLY `CARGO_HOME`, and cargo wants to take a
lock there; jdtls is a JVM under a 6 GiB address-space rlimit. Both are the
shape of problem Go had with `/proc`, and both degrade to a language that
does not answer rather than a daemon that does not run.
+1 -1
View File
@@ -76,7 +76,7 @@ test:
# a number at or below what is published publishes the next patch above the
# registry. So this line is the floor, not the output, and it can never mint a
# tag that already covers different bytes.
version: 0.1.5
version: 0.1.6
images:
- name: lsp
+125 -3
View File
@@ -17,8 +17,9 @@ package lsp
// adding a language to production is adding a toolchain to the Dockerfile — no
// code change, no entry to remember to write.
//
// Phase 1 ships the Go toolchain and gopls. The other four entries are inert in
// that image and light up the moment their toolchain is installed.
// The image ships every server here. An entry is therefore a claim the
// deployment keeps, and a server that cannot start takes only its own language
// down: build() logs it and the others go on answering.
//
// # Scripts-off, and why the fetch phase does not weaken it
//
@@ -156,7 +157,16 @@ var table = map[string]Lang{
// `cargo fetch` downloads and unpacks; it does not compile, so no build.rs
// runs. `cargo build` would, which is why the fetch is not that.
Fetch: []string{"cargo", "fetch", "--locked"},
Env: []string{"CARGO_HOME=${cache}", "RUSTUP_TOOLCHAIN=stable"},
// No RUSTUP_TOOLCHAIN, because there is no rustup: the image installs the
// component tarballs, so `cargo` and `rustc` are the real binaries and
// read no rust-toolchain.toml. A tenant cannot name a compiler this
// deployment would then go and download — the Rust reading of
// GOTOOLCHAIN=local above.
Env: []string{"CARGO_HOME=${cache}"},
// Offline is belt to the seccomp braces, exactly as GOPROXY=off is: the
// serve phase can obtain no socket, and saying so gets a clean "not
// downloaded" out of cargo instead of a connection that cannot be made.
ServeEnv: []string{"CARGO_NET_OFFLINE=true"},
// The fetch being safe is not enough: rust-analyzer COMPILES AND RUNS
// build.rs and expands proc macros at workspace load, by default. That is
// the same code execution the fetch was careful to avoid, arriving through
@@ -179,6 +189,17 @@ var table = map[string]Lang{
// they land in the TREE, so node_modules is reported repo-relative.
Fetch: []string{"npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund"},
Env: []string{"npm_config_cache=${cache}", "npm_config_update_notifier=false"},
// And this is the other half of that bargain. Left alone,
// typescript-language-server prefers the TREE's own
// node_modules/typescript over the one beside it — it would load and RUN
// dependency-authored JavaScript in the serve phase, which is precisely
// the execution --ignore-scripts declined to perform one phase earlier.
// Naming the path takes the user setting, which wins over the workspace
// lookup, so the server is the image's pinned TypeScript and two
// identical requests cannot be answered by two different compilers.
Init: map[string]any{
"tsserver": map[string]any{"path": "/usr/local/lib/node_modules/typescript/lib/tsserver.js"},
},
},
"python": {
Name: "python",
@@ -204,6 +225,107 @@ var table = map[string]Lang{
// compile_commands.json when the tree carries one, and degrades to
// single-file mode when it does not.
},
"java": {
Name: "java",
// The project's own launcher rather than a hand-written `java -jar`. It
// already asks Equinox for a CASCADED configuration, which is the one
// thing an OSGi application needs to run from a read-only installation,
// and copying that incantation here would be a second copy to keep right.
//
// What it does not do is name the WRITABLE half, and Equinox's fallback
// for that is the home directory out of /etc/passwd — not $HOME, which is
// why setting HOME does not contain it. In this image that is /home/lsp,
// a path the jail does not bind, so the bundle cache would be written
// nowhere. Naming it puts the cache and the workspace index on the jail's
// own /tmp, where they die with the process that wrote them.
//
// -Xms256m overrides the launcher's hardcoded -Xms1G: a JVM per Java root
// under a 6 GiB address-space ceiling is no place to commit a gigabyte
// before the first question. The last -Xms wins and the launcher appends
// these after its own.
Start: []string{"jdtls",
"--jvm-arg=-Xms256m",
"--jvm-arg=-Xmx1G",
"--jvm-arg=-Dosgi.configuration.area=/tmp/jdtls/config",
// jdtls writes .project, .classpath and .settings/ into the PROJECT by
// default. The tree is read-only while serving, so that is a failed
// import rather than a modified tree; this keeps them in the workspace
// metadata, which is where a server's bookkeeping belongs anyway.
"--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false",
"-data", "/tmp/jdtls/data",
},
Roots: []string{"pom.xml", "build.gradle", "build.gradle.kts"},
Exts: []string{".java"},
// No fetch, and unlike C++ it is not for want of a resolver — Java has
// two. It is that both of them ARE build tools. Maven resolves in-process
// through m2e and runs the pom's plugins; Gradle runs the build script
// itself, and its wrapper first downloads whichever distribution the tree
// names. Every Java dependency fetch is dependency code executing, so
// there is none, and jdtls answers from the tree's source and the JDK's.
Env: []string{"JAVA_HOME=/usr/local/jdk"},
// The same execution arriving through the SERVER instead of the fetch —
// the shape rust's Init refuses. Both importers off, the Gradle wrapper's
// download off, and autobuild off because a build is where an annotation
// processor on the classpath would run. Name resolution needs none of
// them: JDT indexes source, and that index is what answers.
//
// FLAT dotted keys, all of them. jdtls looks the literal dotted key up
// first and only then walks nested maps, so either spelling works alone —
// but a half-nested one matches nothing and says so nowhere.
Init: map[string]any{
"settings": map[string]any{
"java.import.maven.enabled": false,
"java.import.gradle.enabled": false,
"java.import.gradle.wrapper.enabled": false,
"java.maven.downloadSources": false,
"java.eclipse.downloadSources": false,
"java.autobuild.enabled": false,
},
},
},
"php": {
Name: "php",
Start: []string{"intelephense", "--stdio"},
Roots: []string{"composer.json"},
Exts: []string{".php"},
Cache: "composer",
// --no-scripts --no-plugins is composer's own spelling of scripts-off, and
// it is the bargain npm's --ignore-scripts makes: vendor/ is placed, none
// of its authors' code runs. vendor/ lands in the TREE, so an answer
// inside a dependency is reported repo-relative.
Fetch: []string{"composer", "install", "--no-scripts", "--no-plugins", "--no-interaction"},
Env: []string{"COMPOSER_HOME=${cache}"},
// intelephense keeps its index on disk and defaults it under the home
// directory. /tmp is the jail's own tmpfs, so naming it makes the index
// per-process and it dies with the server — and there is nothing to
// invalidate, because a root is immutable.
Init: map[string]any{
"storagePath": "/tmp/intelephense/workspace",
"globalStoragePath": "/tmp/intelephense/global",
},
},
"ruby": {
Name: "ruby",
Start: []string{"ruby-lsp"},
Roots: []string{"Gemfile"},
Exts: []string{".rb", ".rake", ".gemspec"},
Cache: "bundle",
// `bundle install` COMPILES: a gem with a native extension runs its
// extconf.rb and then make, as us. So this one executes and therefore does
// not run, the same answer python gets. ruby-lsp indexes the interpreter's
// own library directory and the core signatures shipped inside the rbs
// gem, so the standard library resolves without any of it.
Fetch: []string{"bundle", "install"},
Executes: true,
// BUNDLE_GEMFILE IS LOAD-BEARING, and not for what it names. ruby-lsp's
// launcher asks only whether this variable is SET; when it is not, it
// creates a .ruby-lsp directory in the tree and shells out to `bundle
// install` before the server starts — a write to a read-only tree and a
// download in the phase that has no socket. Any value at all takes that
// path off the table, and this one names nothing, so what gets indexed is
// the workspace and the interpreter, never a half-installed bundle.
Env: []string{"BUNDLE_GEMFILE=/nonexistent/Gemfile", "BUNDLE_PATH=${cache}"},
},
}
// Fetchable reports whether l's dependency fetch may run. See the scripts-off
+17 -3
View File
@@ -70,8 +70,12 @@ var toolchainRead = []string{
"/usr/libexec",
"/usr/include",
"/usr/share",
"/usr/local", // /usr/local/go, /usr/local/bin/gopls
"/lib", // ld-linux and libc on merged-usr and split layouts
"/usr/local", // /usr/local/go, the JDK, jdtls, clangd, node and every server
// Debian puts installed gems here rather than under /usr, and RubyGems finds
// them by this path with no environment set. It is the whole of ruby-lsp's
// wiring; binding it is cheaper than teaching the jail a GEM_HOME.
"/var/lib/gems",
"/lib", // ld-linux and libc on merged-usr and split layouts
"/lib64",
"/bin",
"/sbin",
@@ -271,8 +275,18 @@ func (s *Service) serve(ctx context.Context, l Lang, dir string) (*Conn, error)
// No CPU ceiling: RLIMIT_CPU is total seconds, and on a warm server
// that is a scheduled death. The bound on a runaway server is the
// pod's cgroup and the idle TTL.
//
// RLIMIT_NPROC is not a per-server ceiling and never was: it is per
// UID, and every jail here runs as the same one, so the four live
// roots and the daemon's own threads spend a single budget. At 256
// that budget is a way for one language to starve another — a JVM
// carries some forty threads before it has answered anything, so
// three Java roots could leave the fifth server unable to fork and
// the log would blame the server that asked last. 2048 keeps the
// fork-bomb bound this is actually for while leaving the real
// ceiling where it belongs: address space, and the pod's cgroup.
AddrMiB: 6144, FsizeMiB: 256,
Nofile: 4096, Nproc: 256, TmpMiB: 2048,
Nofile: 4096, Nproc: 2048, TmpMiB: 2048,
},
})
return attach(ctx, cmd, l, dir)