Files
風雪and風雪 7129cc346d perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
perf(memory): keep install, update and runtime inside a small-RAM budget (#83)
* perf(memory): keep install, update and runtime inside a small-RAM budget
Small NAT/VPS targets have 128-512MB of RAM and no swap, so anything that
cannot be reclaimed is fatal. This tuning touches no swap and no mmap; it
works by not allocating in the first place.
Installer (covers install and update; update.sh only execs install.sh):
- Put the scratch directory under DATA_DIR instead of mktemp -d. /tmp is
tmpfs on many of these hosts, and without swap every byte there is pinned
in RAM: bundle, extracted release and a copy of the data directory were
held at once, exceeding total RAM of the machines being targeted.
- Hash assets on the way to disk instead of re-reading them. Download is a
single pass now; verification still completes before tar sees the bytes.
- Release the intermediate data.tar right after extraction rather than
holding the data directory twice for the rest of the run.
- Check scratch capacity before downloading, so we fail before the old
service is stopped rather than halfway through.
- Refresh Environment=GOMEMLIMIT/GOGC/THREE_M_UI_CORE_LOW_MEMORY and
MemoryMax on an existing unit. write_service() keeps an operator's unit,
which meant the values baked in at install time never tracked updates.
Panel:
- Bound the Go heap from the process itself via debug.SetMemoryLimit, so the
tuning reaches manual runs, containers and the installer's own init and
healthcheck instead of only a systemd unit. It acts in-process, so the
Mihomo core never inherits the panel's tighter GC target. An explicit
GOMEMLIMIT or GOGC always wins.
- Emit geodata-loader: memconservative for the core when low-memory mode is
on. The key is omitempty and pinned after user fragments merge, so nothing
changes for anyone who does not opt in, and no fragment can undo it.
No new calls to Mmap/Munmap/Madvise, no swap or mmap anywhere in the tree.
* fix(ci): keep the new scratch directory out of the installer's own paths
Two problems CI caught on the small-memory change, both from moving the
scratch directory from mktemp into DATA_DIR.
restore_files() rebuilds the data directory by deleting everything in it
except backups. The scratch directory now lives inside it, so a rollback
deleted the very directory it was reading its own rollback out of, and the
restore died looking for restore/ENABLED. Rollback therefore had to be
skipped, exempting the scratch directory from that purge; the cleanup trap
already removes it.
The test curl also modelled only one of curl's output modes. Writing to
stdout and answering -I are both normal curl behaviour, and both are now
covered, so the mock no longer pins the installer to a single invocation
shape. The distribution fake likewise emits its tampered asset on stdout
instead of through --output, keeping that test about checksum rejection
rather than about which flag was used.
---------
Co-authored-by: 風雪 <kazeyukiro@users.noreply.github.com>
2026-09-27 09:27:35 +08:00

329 lines
15 KiB
Python

#!/usr/bin/env python3
"""Exercise installation/update/restore entirely in temporary directories.
Network, init system, root identity, binaries and sleep are mocked. No host
service, root directory, release or proxy is modified by these tests.
"""
import hashlib
import io
import os
from pathlib import Path
import shutil
import subprocess
import tarfile
import tempfile
import unittest
SCRIPTS = Path(__file__).resolve().parents[1]
PANEL = '''#!/bin/sh
set -eu
case "${1:-}" in
--version) echo "3m-ui @VERSION@";;
storage-paths)
if [ -n "${TEST_EXTERNAL_STORAGE:-}" ]; then echo "$TEST_EXTERNAL_STORAGE"; else echo "$THREE_M_UI_CONFIG"; fi;;
init)
mkdir -p "$(dirname "$THREE_M_UI_CONFIG")" "$THREE_M_UI_DATA_DIR"
if [ ! -f "$THREE_M_UI_CONFIG" ]; then
echo "port: 8080" > "$THREE_M_UI_CONFIG"
echo "old-db" > "$THREE_M_UI_DATA_DIR/3m-ui.db"
echo "Initial password: random-one-time-test-password"
fi
if [ "@VERSION@" = v2.0.0-badinit ]; then
echo corrupted > "$THREE_M_UI_CONFIG"
echo migrated > "$THREE_M_UI_DATA_DIR/3m-ui.db"
echo new > "$THREE_M_UI_DATA_DIR/created-by-migration"
exit 1
fi;;
healthcheck) [ "@VERSION@" != v2.0.0-badhealth ];;
*) exit 2;;
esac
'''
CURL = '''#!/usr/bin/env python3
import os, pathlib, sys, shutil
# Models the parts of curl the installer actually uses:
# -o FILE write the response body to FILE
# (no -o) write the response body to stdout
# -I emit response headers only, including Content-Length
args=sys.argv[1:]
url=next(a for a in args if a.startswith('https://'))
with open(os.environ['TEST_EVENTS'], 'a') as f: f.write('download '+url+'\\n')
if url.endswith('/releases/latest'):
print(url[:-len('/releases/latest')]+'/releases/tag/'+os.environ.get('TEST_LATEST','v1.0.0'),end='')
else:
path=url.split('/releases/download/',1)[1]
source=pathlib.Path(os.environ['TEST_RELEASES'])/path
if not source.is_file(): sys.exit(22)
if '-I' in args:
sys.stdout.write('HTTP/1.1 200 OK\\r\\nContent-Length: %d\\r\\n\\r\\n' % source.stat().st_size)
elif '-o' in args:
shutil.copyfile(source,args[args.index('-o')+1])
else:
sys.stdout.buffer.write(source.read_bytes())
'''
SYSTEMCTL = '''#!/usr/bin/env python3
import os,pathlib,sys
root=pathlib.Path(os.environ['THREE_M_UI_ROOT'])
a=sys.argv[1]
active=root/'running'
enabled=root/'enabled'
with open(os.environ['TEST_EVENTS'],'a') as f: f.write('service '+a+'\\n')
if a=='is-active': sys.exit(0 if active.exists() else 3)
if a=='is-enabled': sys.exit(0 if enabled.exists() else 1)
if a=='start':
version=(root/'usr/local/lib/3m-ui/VERSION').read_text().strip()
if version=='v2.0.0-badstart': sys.exit(1)
active.touch()
if a=='stop': active.unlink(missing_ok=True)
if a=='enable': enabled.touch()
if a=='disable': enabled.unlink(missing_ok=True)
'''
class LifecycleTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix='3m-lifecycle-')
self.directory = Path(self.tmp.name)
self.root = self.directory / 'root'
self.root.mkdir()
(self.root / 'run/systemd/system').mkdir(parents=True)
self.release_root = self.directory / 'releases'
self.release_root.mkdir()
self.mock = self.directory / 'mock-bin'
self.mock.mkdir()
self.events = self.directory / 'events'
self.events.touch()
self.env = os.environ.copy()
self.env.update(THREE_M_UI_ROOT=str(self.root),
THREE_M_UI_REPO='example/3m-ui',
TEST_EVENTS=str(self.events), TEST_RELEASES=str(self.release_root),
PATH=str(self.mock) + os.pathsep + self.env['PATH'])
for name, source in {
'curl': CURL, 'systemctl': SYSTEMCTL,
'id': '#!/bin/sh\necho 0\n',
'uname': '#!/bin/sh\ncase "$1" in -m) echo x86_64;; -s) echo Linux;; esac\n',
'sleep': '#!/bin/sh\nexit 0\n',
}.items():
self.executable(self.mock / name, source)
self.make_release('v1.0.0')
def tearDown(self):
self.tmp.cleanup()
@property
def base(self): return self.root / 'usr/local/lib/3m-ui'
@property
def config(self): return self.root / 'etc/3m-ui'
@property
def data(self): return self.root / 'var/lib/3m-ui'
def executable(self, path, source):
path.write_text(source)
path.chmod(0o755)
def make_release(self, version):
destination = self.release_root / version
destination.mkdir()
panel = PANEL.replace('@VERSION@', version).encode()
members = {
'3m-ui-bin': panel, 'mihomo': b'#!/bin/sh\necho Mihomo test\n',
'VERSION': version.encode(), 'REPOSITORY': b'example/3m-ui',
'MIHOMO_VERSION': b'v1.19.0', 'mihomo.env': b'MIHOMO_VERSION=v1.19.0',
'LICENSE': b'Panel license', 'MIHOMO_LICENSE': b'Core license',
'MIHOMO_SOURCE': b'https://github.com/MetaCubeX/mihomo/tree/v1.19.0',
}
members.update({name: (SCRIPTS / name).read_bytes() for name in
['install.sh', 'update.sh', 'uninstall.sh', '3m-ui', '3m-ui.sh']})
archive = destination / '3m-ui-bundle-linux-amd64.tar.gz'
with tarfile.open(archive, 'w:gz') as tar:
for name, content in members.items():
info = tarfile.TarInfo(name)
info.size = len(content)
info.mode = 0o755
tar.addfile(info, io.BytesIO(content))
for name in ['install.sh', 'update.sh', 'uninstall.sh', '3m-ui', '3m-ui.sh']:
(destination / name).write_bytes(members[name])
(destination / '3m-ui-linux-amd64').write_bytes(panel)
(destination / 'SHA256SUMS').write_text(''.join(
hashlib.sha256(path.read_bytes()).hexdigest() + ' ' + path.name + '\n'
for path in sorted(destination.iterdir())))
def run_script(self, name='install.sh', *args, success=True, **env):
run_env = self.env | env
result = subprocess.run(['sh', str(SCRIPTS / name), *args], env=run_env,
capture_output=True, text=True)
if success:
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
else:
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
return result
def install(self): return self.run_script('install.sh', '--yes')
def test_default_stable_complete_install(self):
result = self.install()
self.assertEqual((self.base / 'VERSION').read_text().strip(), 'v1.0.0')
self.assertEqual((self.base / 'CHANNEL').read_text().strip(), 'stable')
self.assertEqual((self.base / 'REPOSITORY').read_text().strip(), 'example/3m-ui')
self.assertTrue((self.base / 'mihomo').is_file())
self.assertTrue((self.root / 'running').is_file())
self.assertIn('Initial password:', result.stdout)
for directory in (self.base, self.config, self.data):
for file in directory.rglob('*'):
if file.is_file() and file.suffix != '.gz':
# The fake binary embeds the test fixture, so inspect actual state only.
if file.parent == self.config or file.name == '3m-ui.db':
self.assertNotIn(b'random-one-time-test-password', file.read_bytes())
self.assertIn('/releases/latest', self.events.read_text())
self.assertNotIn('/download/pre/', self.events.read_text())
def test_explicit_pre_channel_persists(self):
self.make_release('pre')
self.run_script('install.sh', 'pre', '--yes')
self.events.write_text('')
self.run_script('update.sh', '--yes', THREE_M_UI_REPO='')
self.assertEqual((self.base / 'CHANNEL').read_text().strip(), 'pre')
self.assertIn('https://github.com/example/3m-ui/releases/download/pre/', self.events.read_text())
self.assertNotIn('/releases/latest', self.events.read_text())
def test_semver_build_metadata_can_be_installed(self):
self.make_release('v1.0.0+build.1')
self.run_script('install.sh', 'v1.0.0+build.1', '--yes')
self.assertEqual((self.base / 'VERSION').read_text().strip(), 'v1.0.0+build.1')
def test_bad_checksum_keeps_service_and_every_file(self):
self.install()
old = (self.base / '3m-ui-bin').read_bytes()
self.make_release('v2.0.0')
(self.release_root / 'v2.0.0/3m-ui-bundle-linux-amd64.tar.gz').write_bytes(b'broken')
self.events.write_text('')
result = self.run_script('update.sh', 'v2.0.0', '--yes', success=False)
self.assertIn('Checksum mismatch', result.stderr)
self.assertNotIn('service stop', self.events.read_text())
self.assertEqual((self.base / '3m-ui-bin').read_bytes(), old)
self.assertTrue((self.root / 'running').is_file())
def add_persistent_state(self):
(self.config / 'config.yaml').write_text('custom config and encryption keys')
(self.data / '3m-ui.db-wal').write_text('wal-state')
(self.data / 'certificates').mkdir(exist_ok=True)
(self.data / 'certificates/listener.key').write_text('private-key')
(self.base / 'custom').write_text('preserve-extra')
def assert_persistent_state(self):
self.assertEqual((self.config / 'config.yaml').read_text(), 'custom config and encryption keys')
self.assertEqual((self.data / '3m-ui.db').read_text().strip(), 'old-db')
self.assertEqual((self.data / '3m-ui.db-wal').read_text(), 'wal-state')
self.assertEqual((self.data / 'certificates/listener.key').read_text(), 'private-key')
self.assertEqual((self.base / 'custom').read_text(), 'preserve-extra')
self.assertFalse((self.data / 'created-by-migration').exists())
def test_failed_init_restores_complete_state(self):
self.install()
self.add_persistent_state()
old_entry = (self.root / 'usr/local/bin/3m-ui').read_bytes()
self.make_release('v2.0.0-badinit')
self.events.write_text('')
self.run_script('update.sh', 'v2.0.0-badinit', '--yes', success=False)
self.assert_persistent_state()
self.assertEqual((self.base / 'VERSION').read_text().strip(), 'v1.0.0')
self.assertEqual((self.root / 'usr/local/bin/3m-ui').read_bytes(), old_entry)
self.assertTrue((self.root / 'running').is_file())
events = self.events.read_text().splitlines()
self.assertLess(max(i for i, line in enumerate(events) if line.startswith('download ')),
next(i for i, line in enumerate(events) if line == 'service stop'))
def test_failed_start_and_health_restore(self):
for version in ['v2.0.0-badstart', 'v2.0.0-badhealth']:
with self.subTest(version=version):
self.install()
self.add_persistent_state()
self.make_release(version)
self.run_script('update.sh', version, '--yes', success=False)
self.assert_persistent_state()
self.assertEqual((self.base / 'VERSION').read_text().strip(), 'v1.0.0')
self.assertTrue((self.root / 'running').is_file())
def test_backup_restore_and_retained_archives(self):
self.install()
self.add_persistent_state()
result = self.run_script('install.sh', '--backup')
snapshot = Path(next(line.removeprefix('Snapshot: ') for line in result.stdout.splitlines() if line.startswith('Snapshot: ')))
with tarfile.open(snapshot) as tar:
self.assertFalse(any('/backups/' in name for name in tar.getnames()))
self.assertIn('./data/3m-ui.db-wal', tar.getnames())
(self.data / '3m-ui.db').write_text('changed')
(self.data / 'certificates/listener.key').unlink()
self.run_script('install.sh', '--restore', str(snapshot), '--yes')
self.assert_persistent_state()
self.assertTrue(snapshot.exists())
self.assertTrue((self.root / 'running').is_file())
def test_external_storage_rejected_before_stop(self):
self.install()
self.make_release('v2.0.0')
self.events.write_text('')
result = self.run_script('update.sh', 'v2.0.0', success=False, TEST_EXTERNAL_STORAGE='/srv/shared/cert.pem')
self.assertIn('Storage outside', result.stderr)
self.assertNotIn('service stop', self.events.read_text())
self.assertTrue((self.root / 'running').is_file())
def test_uninstall_keeps_data_config_external_core(self):
self.install()
self.add_persistent_state()
external = self.root / 'usr/local/bin/mihomo'
external.write_text('external-core')
self.run_script('uninstall.sh', '--yes')
self.assertTrue((self.config / 'config.yaml').is_file())
self.assertTrue((self.data / 'certificates/listener.key').is_file())
self.assertEqual(external.read_text(), 'external-core')
self.assertFalse(self.base.exists())
self.run_script('uninstall.sh', '--yes', '--purge')
self.assertFalse(self.config.exists())
self.assertFalse(self.data.exists())
self.assertTrue(external.is_file())
def test_failed_first_install_removes_service_and_enablement(self):
self.make_release('v2.0.0-badstart')
self.run_script('install.sh', 'v2.0.0-badstart', '--yes', success=False)
self.assertFalse(self.base.exists())
self.assertFalse((self.root / 'etc/systemd/system/3m-ui.service').exists())
self.assertFalse((self.root / 'enabled').exists())
self.assertFalse((self.root / 'running').exists())
def test_failed_snapshot_restarts_old_service(self):
self.install()
self.make_release('v2.0.0')
real_tar = shutil.which('tar')
self.executable(self.mock / 'tar', '#!/bin/sh\nif [ "$1" = -czf ]; then exit 1; fi\nexec "' + real_tar + '" "$@"\n')
self.run_script('update.sh', 'v2.0.0', '--yes', success=False)
self.assertEqual((self.base / 'VERSION').read_text().strip(), 'v1.0.0')
self.assertTrue((self.root / 'running').exists())
def test_custom_data_directory_persists_for_updates(self):
custom = self.root / 'srv/3m-ui-data'
self.run_script('install.sh', '--yes', THREE_M_UI_DATA_DIR=str(custom))
self.make_release('v2.0.0')
self.run_script('update.sh', 'v2.0.0', '--yes')
self.assertEqual((self.base / 'DATA_DIRECTORY').read_text().strip(), str(custom))
self.assertTrue((custom / '3m-ui.db').is_file())
self.assertFalse((self.data / '3m-ui.db').exists())
def test_latest_never_falls_back_to_pre(self):
self.make_release('pre')
result = self.run_script('install.sh', '--yes', success=False, TEST_LATEST='pre')
self.assertIn('Unable to resolve a stable release', result.stderr)
self.assertNotIn('/download/pre/', self.events.read_text())
self.assertFalse(self.base.exists())
def test_no_mihomo_is_explicit_standalone(self):
self.run_script('install.sh', '--no-mihomo', '--yes')
self.assertTrue((self.base / '3m-ui-bin').is_file())
self.assertFalse((self.base / 'mihomo').exists())
if __name__ == '__main__':
unittest.main()