Skip to content

feat: disk-backed /tmp and a configurable guest swapfile - #5

Merged
stempler merged 4 commits into
mainfrom
feat/guest-swap-disk-tmp
Sep 17, 2026
Merged

stempler merged 4 commits into
mainfrom
feat/guest-swap-disk-tmp

Conversation

@stempler

Copy link
Copy Markdown
Member

The Ubuntu guest mounts /tmp as a tmpfs sized at half of RAM and ships without swap. Under a Gradle-plus-Testcontainers burst, with agent scratch piling up in /tmp, the guest went straight to the OOM killer, which took dockerd out repeatedly and left the VM unresponsive.

Provisioning now masks tmp.mount and unmounts the tmpfs on the boot it first runs (deferred to the next boot only if /tmp is busy), and creates /swapfile sized by a new swap config key (default 4GiB, 0B disables). The swapfile is resized or removed to follow the config, fstab mirrors what is on disk, and a swap setup failure is logged rather than failing the boot. With swap present the agent slice's MemoryMax swaps instead of OOM-killing.

The integration suite asserts /tmp is on the root filesystem without a deferral, and that the swapfile is active and in fstab.

The Ubuntu guest mounts /tmp as a tmpfs sized at half of RAM and ships
without swap. Under a Gradle-plus-Testcontainers burst, with agent
scratch piling up in /tmp, the guest went straight to the OOM killer,
which took dockerd out repeatedly and left the VM unresponsive.

Provisioning now masks tmp.mount and unmounts the tmpfs on the boot it
first runs (deferred to the next boot only if /tmp is busy), and creates
/swapfile sized by a new `swap` config key (default 4GiB, 0B disables).
The swapfile is resized or removed to follow the config, fstab mirrors
what is on disk, and a swap setup failure is logged rather than failing
the boot. With swap present the agent slice's MemoryMax swaps instead of
OOM-killing.

The integration suite asserts /tmp is on the root filesystem without a
deferral, and that the swapfile is active and in fstab.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Swap conversion can abort provisioning, masking failures are hidden, and resize/disable transitions lack integration coverage.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds disk-backed /tmp and configurable guest swap to reduce OOM failures.

Changes:

  • Adds and validates the swap configuration.
  • Provisions, resizes, disables, and activates /swapfile.
  • Masks tmp.mount and adds integration checks.
File summaries
File Description
README.md Documents swap and disk-backed /tmp.
test-vm-sandbox.sh Verifies /tmp and default swap setup.
internal/config/config.go Adds swap configuration and validation.
internal/config/config_test.go Tests swap defaults and syntax.
internal/lima/template.go Exports swap size to provisioning.
internal/lima/template_test.go Tests swap-size rendering.
internal/lima/testdata/golden-template.yaml Updates expected rendered environment.
internal/guest/files/scripts/provision-system.sh Implements /tmp unmounting and swap management.
Review details
  • Files reviewed: 8/8 changed files
  • Comments generated: 3
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/guest/files/scripts/provision-system.sh
Comment thread internal/guest/files/scripts/provision-system.sh Outdated
Comment thread internal/guest/files/scripts/provision-system.sh
Address the review on the swap/tmp change.

- Validate the swap size host-side: it must parse into 64 bits and be
  smaller than the disk. Previously a value like 999999999999999999999TiB
  passed the shape check and made numfmt exit 2 in the guest, which under
  set -e aborted provisioning instead of degrading to no swap. The guest
  now also guards that conversion and leaves existing swap untouched on
  a parse failure rather than treating it as a request for 0B.
- Check that tmp.mount actually reports as masked and warn otherwise: the
  same-boot unmount would make the boot look right even if the mask had
  silently failed. The suite asserts the masked state directly.
- Exercise the swap transitions in the suite by setting swap to 0B before
  the first restart-hygiene reboot and to 1GiB before the second, asserting
  the file, active swap list and fstab after each. No extra reboots.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Failed swap activation leaves a persistent fstab entry that causes repeated failing swap units on later boots.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

internal/guest/files/scripts/provision-system.sh:233

  • The fstab entry is added before activation is known to work. If swapon fails (the explicitly handled failure path), the entry remains, so every subsequent boot generates and retries a failing swap unit even though provisioning reports that the guest is running without swap. Add the persistent entry only after the file is active, and remove any managed entry on activation failure.
  • Files reviewed: 8/8 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

The fstab entry was written before swapon ran. Had swapon failed, the
entry stayed, giving every later boot a failing swap unit, and since the
file was already the right size provisioning never rebuilt it either.

Activate first, persist second. On activation failure the swapfile is
removed along with any fstab line, so the next boot starts clean and
retries creation instead of re-activating a file known not to work.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@stempler

Copy link
Copy Markdown
Member Author

Re the second Copilot pass (fstab entry written before swapon was known to succeed): fixed in 03368b8. Activation now runs first, and the fstab line is added only once the swapfile is active. On activation failure the file is removed together with any managed fstab line, so the next boot starts clean and retries creation rather than re-activating a file known not to work. The failure path itself is not integration-tested, since swapon cannot cheaply be made to fail on the ext4 root; the happy paths and both transitions remain covered. Full suite after the change: 124 pass, 0 fail.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Validation accepts swap sizes that the guest cannot process or format successfully.

Review details

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

internal/config/config.go:166

  • parseSize accepts unsigned 64-bit values, but the rendered value is later compared with Bash [ integer operators, which reject values above MaxInt64. For example, swap: 8388609TiB with a larger valid disk passes this validation, then provisioning prints integer expression expected and skips reconciliation, leaving swap absent or stale. Bound the swap value to the guest shell's signed range and add a boundary test.

This issue also appears on line 171 of the same file.

internal/config/config.go:173

  • The validation has no lower bound for enabled swap, so values such as swap: 1B are accepted even though mkswap cannot format such a file; provisioning then deterministically removes it and runs without the configured swap. Reject nonzero sizes below a documented usable minimum (while continuing to allow zero as the disable value).
	if swap >= disk {
		return fmt.Errorf("swap (%s) must be smaller than disk (%s)", c.Swap, c.Disk)
	}
  • Files reviewed: 8/8 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Two edge values passed validation but could never work in the guest:

- Above MaxInt64: numfmt converts it, but bash's `[` rejects it with
  "integer expected", so every swap step was silently skipped.
- Below mkswap's minimum area: formatting failed deterministically and
  the guest ran without the configured swap.

Validate now caps swap at MaxInt64 and requires nonzero sizes to be at
least 1MiB, above mkswap's ten-page minimum on both 4 KiB and 64 KiB page
kernels. 0B stays the disable value.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@stempler

Copy link
Copy Markdown
Member Author

Re the third Copilot pass (config.go:166 and :173): both fixed in 271635e. Validate now rejects swap above MaxInt64, since bash's [ in the guest stops there and would silently skip every swap step, and requires nonzero sizes to be at least 1MiB, above mkswap's ten-page minimum on both 4 KiB and 64 KiB page kernels. 0B remains the disable value. Boundary cases on both sides of each limit are in the table-driven TestValidate, and the README states the bounds. No guest script change, so the integration suite result stands.

@stempler
stempler merged commit 0f6082c into main Sep 17, 2026
7 checks passed
@stempler
stempler deleted the feat/guest-swap-disk-tmp branch September 17, 2026 15:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants