Skip to content

fix(manager): the guest keeps its writes when stopped, and a login can be canceled - #18

Merged
LYJW131 merged 1 commit into
litefrom
fix/manager-guest-durable
Nov 3, 2026
Merged

LYJW131 merged 1 commit into
litefrom
fix/manager-guest-durable

Conversation

@LYJW131

@LYJW131 LYJW131 commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

amdl stopped the QEMU guest by killing it, and ext4's delayed allocation kept about 30 s of writes in guest memory, so a payload installed just before a stop was downloaded again and a fresh login's instances.json and tokens could be lost.

  • guest/init: /data mounts with commit=1,nodelalloc (falls back to the defaults); a disk that holds ext4 but does not mount is never formatted; sync and unmount before poweroff -f.
  • POST /shutdown, only in the guest (WRAPPER_MANAGER_GUEST=1 from init): sync, answer, stop as on SIGTERM. amdl calls it before it falls back to killing QEMU.
  • instances.json and .payload-source are written with fsync and a synced rename; the payload and a login's tokens are synced before they count.
  • With -payload-sha256 the digest alone names the installed payload: amdl now serves the zip to the guest from the host on a new port each boot.
  • Login cancel: /login follows the request context. A client that goes away, or the 90 s login wait, kills the login child and removes its instance dir before the answer; /login gets a 105 s write deadline instead of the server's 60 s. A new login of an account replaces one still in flight (e.g. waiting for a code), and a replaced login's late failure no longer removes the new login's dir.

Tests: go test ./... and -race on the login and shutdown tests.

🤖 Generated with Claude Code


Devin Review

…n be canceled

A host that stopped the QEMU guest killed it, and ext4's delayed allocation
kept ~30 s of written data in guest memory: an installed payload, or an
instances.json and the tokens of a fresh login, were lost with it.

- init mounts /data with commit=1,nodelalloc, falls back to the defaults,
  formats only a disk with no ext4 magic, and syncs and unmounts before
  powering off.
- POST /shutdown, served only in the guest (init sets
  WRAPPER_MANAGER_GUEST=1), syncs and stops the manager, so the host can
  power the guest off instead of killing it.
- instances.json and .payload-source are written with fsync and a synced
  rename; the payload's files and a login's tokens are synced before they
  are counted on.
- With -payload-sha256 the digest alone names the installed payload, so a
  host may serve the same zip from a new URL each boot without a reinstall.

A login honours the request's context: when the client goes away, or the
90 s login wait passes, its child is killed and its instance dir removed
before the answer, and POST /login gets a write deadline past that wait
instead of the server's 60 s. A new login of an account replaces one still
in flight, and a replaced login's late failure no longer removes the new
login's dir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: LYJW131 <153256373+LYJW131@users.noreply.github.com>

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 3 potential issues.

Devin Review

Comment thread manager/login.go
Comment on lines +136 to +139
select {
case <-st.exited:
case <-time.After(abortWait):
log.Warnf("[wrapper %s] the canceled login's child did not exit within %s", shortID(st.Id), abortWait)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Canceled login still starts its service

If the login child exits before cancellation, abortLogin returns while service startup continues. st.exited tracks only the login child, so the canceled account can still start and persist.

Learn more

A successful login has two phases: runLoginChild waits for the one-shot login child, then finishLoginSuccess starts a long-running service in a separate goroutine. st.exited closes when the first phase returns, before service startup completes. Canceling during the second phase therefore returns immediately without stopping that service. The service can persist the account after the caller sees a canceled login, and a retry can erase its token directory.

Example: The login child writes tokens and exits after two seconds. The service takes ten seconds to report ready. The client disconnects at second three; abortLogin returns, but the service later becomes ready and saves the account.

Recommended fix: Track and cancel the whole login lifecycle, including the goroutine in finishLoginSuccess. Do not resolve cancellation or start another attempt until any service process is stopped, reaped, and its directory cleanup is complete.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in #20.

Comment thread manager/login.go
Comment on lines +426 to +428
if releaseLogin(id, st) {
_ = RemoveWrapperDataQuiet(id)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Failed login cleanup erases retry data

When a retry starts after loginFailed releases its registry entry, the old cleanup can erase the retry's directory. reserveLogin admits the retry before RemoveWrapperDataQuiet completes, so its tokens can disappear.

Learn more

The login registry serializes attempts for the same account, and both attempts use the same directory. Here releaseLogin frees that registry slot before RemoveWrapperDataQuiet removes the directory. A concurrent request can reserve the freed slot and start a child in that directory before removal finishes. The old cleanup then deletes files owned by the new login.

Example: An old login exits unsuccessfully and frees its slot. A retry creates STOREFRONT_ID in the same directory while the old RemoveAll runs. The retry loses its token file and cannot complete successfully.

Recommended fix: Keep the old login reserved until its child is reaped and directory cleanup finishes. Release the registry entry atomically with making the account available to a new attempt, while retaining pointer checks to protect replacements.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in #20.

Comment thread manager/main.go
Comment on lines +126 to +128
if os.Getenv(guestEnv) == "1" {
mux.HandleFunc("/shutdown", handleShutdown(sigCh))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟥 Unauthenticated shutdown powers off the guest

Any client reaching the forwarded guest port can call POST /shutdown without credentials. The request stops the manager, and guest init powers off the VM.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Kept as is: /shutdown exists only in the QEMU guest (init sets WRAPPER_MANAGER_GUEST), whose port amdl forwards to 127.0.0.1 only, where /login and /logout are just as open. A container manager never serves it.

@LYJW131
LYJW131 merged commit b60beea into lite Nov 3, 2026
7 checks passed
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.

1 participant