Compare commits

..
Author SHA1 Message Date
sjg 99ae2a5fd7 [chore](trx-rs): add sccache compilation cache
CI / reuse (pull_request) Successful in 5s
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 4s
Bake sccache into the SDK image and enable it via RUSTC_WRAPPER in CI and
the devcontainer (not repo-wide, so non-SDK builds are unaffected).

- container/Containerfile: install the sccache musl binary.
- ci.yml: RUSTC_WRAPPER=sccache, CARGO_INCREMENTAL=0, SCCACHE_DIR=/sccache,
  cache size cap, plus a `sccache --show-stats` step per job.
- runner-config.example.yaml: bind-mount /var/cache/sccache into job
  containers so the cache persists across runs and is shared between jobs.
- .devcontainer: enable sccache with a named cache volume.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-19 16:43:59 +02:00
sjg 9da6118a35 [chore](trx-rs): add OpenRC service for act_runner on Alpine
CI / lint (pull_request) Successful in 5m11s
CI / test (pull_request) Successful in 13m48s
CI / reuse (pull_request) Successful in 46s
The runner host is Alpine (OpenRC, no systemd). Add an OpenRC init script
for act_runner (supervise-daemon, depends on docker) plus a conf.d
example for running one instance per project, and rewrite the runner
section of the README with Alpine setup steps (apk docker, dedicated user
in the docker group, register, service install).

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-19 13:14:17 +02:00
sjg efd82c6284 [chore](trx-rs): use nested SDK image path trx-rs/sdk
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 1s
CI / reuse (pull_request) Failing after 4s
Match the image name that was pushed to the registry
(git.haxx.space/sjg/trx-rs/sdk) across the workflow, devcontainer and
README.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-19 13:06:43 +02:00
sjg e9cf5e8739 [chore](trx-rs): shared SDK image for CI and developers
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 1s
CI / reuse (pull_request) Failing after 4s
Rework container/ from a host-executor act_runner image into a single
"SDK" build image used everywhere: as the CI job container (Docker
executor) and by developers locally / via .devcontainer. It bakes in a
pinned Rust toolchain and all build dependencies, so CI and every
developer share the exact same rustc/clippy.

- container/Containerfile: SDK image (Debian + deps + pinned Rust + Node).
- rust-toolchain.toml: pin the toolchain to match the image; also ends the
  "CI clippy newer than local" version skew.
- .gitea/workflows/ci.yml: lint/test run inside the SDK image via
  `container:`; reuse returns to fsfe/reuse-action (Docker executor runs
  it as a sibling container, so nothing REUSE-related is baked in).
- .devcontainer/devcontainer.json: dev use of the same image.
- container/runner-config.example.yaml: Docker-executor runner config for
  the CI VM, capped for a 2-thread budget.
- Drop the obsolete host-executor entrypoint/config/Quadlet units.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-19 00:22:27 +02:00
18 changed files with 302 additions and 488 deletions
+22
View File
@@ -0,0 +1,22 @@
{
"name": "trx-rs SDK",
"image": "git.haxx.space/sjg/trx-rs/sdk:latest",
"workspaceFolder": "/work",
"workspaceMount": "source=${localWorkspaceFolder},target=/work,type=bind",
"mounts": [
"source=trx-rs-sccache,target=/sccache,type=volume"
],
"containerEnv": {
"RUSTC_WRAPPER": "sccache",
"CARGO_INCREMENTAL": "0",
"SCCACHE_DIR": "/sccache"
},
"customizations": {
"vscode": {
"extensions": [
"rust-lang.rust-analyzer",
"tamasfe.even-better-toml"
]
}
}
}
+20 -9
View File
@@ -2,10 +2,10 @@
# #
# SPDX-License-Identifier: GPL-2.0-or-later # SPDX-License-Identifier: GPL-2.0-or-later
# CI for the self-hosted, host-executor Podman runners (see container/). # CI for the Docker-executor runner (VM). The lint/test jobs run inside the
# The runner image bakes in the Rust toolchain and all build dependencies, # shared trx-rs SDK image (container/Containerfile), which bakes in the pinned
# so jobs go straight to cargo — no apt/rustup setup steps (which also # Rust toolchain and all build dependencies. The reuse job uses the upstream
# collided on the dpkg lock when jobs ran concurrently in the same runner). # Docker action, which the Docker executor launches as a sibling container.
name: CI name: CI
@@ -16,32 +16,43 @@ on:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
# sccache: shared compilation cache persisted on the runner host (see the
# -v mount in runner-config.example.yaml). CARGO_INCREMENTAL=0 because
# sccache cannot cache incremental artifacts.
RUSTC_WRAPPER: sccache
CARGO_INCREMENTAL: "0"
SCCACHE_DIR: /sccache
SCCACHE_CACHE_SIZE: "20G"
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: git.haxx.space/sjg/trx-rs/sdk:latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: rustfmt - name: rustfmt
run: cargo fmt --all -- --check run: cargo fmt --all -- --check
- name: clippy - name: clippy
run: cargo clippy --workspace --all-targets --all-features -- -D warnings run: cargo clippy --workspace --all-targets --all-features -- -D warnings
- name: sccache stats
if: always()
run: sccache --show-stats
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: git.haxx.space/sjg/trx-rs/sdk:latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Build - name: Build
run: cargo build --workspace --all-targets --locked run: cargo build --workspace --all-targets --locked
- name: Test - name: Test
run: cargo test --workspace --locked run: cargo test --workspace --locked
- name: sccache stats
if: always()
run: sccache --show-stats
reuse: reuse:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: REUSE compliance - uses: fsfe/reuse-action@v5
# `reuse` CLI instead of fsfe/reuse-action: the latter is a Docker
# action, which the host-executor runners cannot run. `reuse` is baked
# into the runner image (see container/Containerfile).
run: reuse lint
+2
View File
@@ -12,6 +12,8 @@ path = [
"trx-rs.toml.example", "trx-rs.toml.example",
"docs/**", "docs/**",
"aidocs/**", "aidocs/**",
"container/**",
".devcontainer/**",
"src/decoders/trx-ftx/README.md", "src/decoders/trx-ftx/README.md",
"src/decoders/trx-wxsat/README.md", "src/decoders/trx-wxsat/README.md",
"assets/trx-logo.png", "assets/trx-logo.png",
+32 -38
View File
@@ -2,60 +2,54 @@
# #
# SPDX-License-Identifier: GPL-2.0-or-later # SPDX-License-Identifier: GPL-2.0-or-later
# Gitea Actions runner image for trx-rs CI (host-executor / "Pattern B"). # trx-rs SDK / build image.
# #
# All build dependencies, the Rust toolchain, Node.js (for JS actions such as # Single source of truth for the build environment. Used two ways:
# actions/checkout and actions/cache) and the `reuse` tool are baked in, so CI # * CI — as the job container for the lint/test jobs (Docker executor).
# runs skip the per-run apt/rustup install cost. `sudo` is present so the # * Dev — run locally or via .devcontainer for a reproducible toolchain.
# existing workflow's `sudo apt-get ...` / rustup steps remain valid — they #
# just become fast no-ops because everything is already installed. # Pinning the Rust version here (and in rust-toolchain.toml) means CI and every
# developer share the exact same rustc/clippy, so "works locally, fails in CI"
# cannot happen.
FROM docker.io/library/debian:bookworm-slim FROM docker.io/library/debian:bookworm-slim
ARG ACT_RUNNER_VERSION=0.2.11 # Keep in sync with rust-toolchain.toml.
ARG RUST_VERSION=1.97.1
ARG NODE_MAJOR=20 ARG NODE_MAJOR=20
ENV DEBIAN_FRONTEND=noninteractive \ ENV DEBIAN_FRONTEND=noninteractive \
RUSTUP_HOME=/opt/rustup \ RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/opt/cargo \ CARGO_HOME=/usr/local/cargo \
PATH=/opt/cargo/bin:/usr/local/bin:/usr/bin:/bin PATH=/usr/local/cargo/bin:/usr/local/bin:/usr/bin:/bin
# Base tooling + trx-rs build dependencies (mirrors .gitea/workflows/ci.yml). # Build dependencies (mirror README's manual instructions).
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl xz-utils git sudo pipx \ ca-certificates curl git \
build-essential pkg-config cmake clang libclang-dev \ build-essential pkg-config cmake clang libclang-dev \
libopus-dev libasound2-dev libsoapysdr-dev \ libopus-dev libasound2-dev libsoapysdr-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Node.js (JS-based actions need node in PATH under the host executor). # Node.js JS-based actions (actions/checkout, actions/cache) run *inside*
# the job container under the Docker executor, so node must be present.
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \ RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \ && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# REUSE >= 3 (Debian's packaged reuse is too old for REUSE.toml). # Pinned Rust toolchain, installed world-readable so any UID the runner or a
# The [charset-normalizer] extra provides an encoding-detection backend; # devcontainer uses can invoke cargo.
# without it (and without libmagic) reuse fails to import at runtime.
RUN PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install 'reuse[charset-normalizer]'
# Rust stable with rustfmt + clippy, installed system-wide.
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal \ | sh -s -- -y --no-modify-path \
--component rustfmt --component clippy \ --default-toolchain "${RUST_VERSION}" --profile minimal \
&& chmod -R a+rwX "$CARGO_HOME" "$RUSTUP_HOME" --component rustfmt --component clippy \
&& chmod -R a+rwX "$RUSTUP_HOME" "$CARGO_HOME"
# act_runner binary. # sccache — shared compilation cache. Enabled at build time via
RUN arch="$(dpkg --print-architecture)"; \ # RUSTC_WRAPPER (see the CI workflow and .devcontainer), not repo-wide, so
case "$arch" in amd64) rarch=amd64;; arm64) rarch=arm64;; *) echo "unsupported arch $arch" >&2; exit 1;; esac; \ # non-SDK builds are unaffected. musl build is static and runs anywhere.
curl -fsSL -o /usr/local/bin/act_runner \ ARG SCCACHE_VERSION=0.8.2
"https://gitea.com/gitea/act_runner/releases/download/v${ACT_RUNNER_VERSION}/act_runner-${ACT_RUNNER_VERSION}-linux-${rarch}" \ RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
&& chmod +x /usr/local/bin/act_runner | tar -xz -C /tmp \
&& install -m755 "/tmp/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl/sccache" /usr/local/bin/sccache \
&& rm -rf /tmp/sccache-*
# Default config template (seeded into the /data volume on first boot). WORKDIR /work
COPY config.yaml /etc/act_runner/config.yaml
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# /data holds the .runner registration, cache and workflow workspaces.
VOLUME /data
WORKDIR /data
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+102 -90
View File
@@ -3,116 +3,128 @@ SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
SPDX-License-Identifier: GPL-2.0-or-later SPDX-License-Identifier: GPL-2.0-or-later
--> -->
# Podman-based Gitea Actions runners # trx-rs SDK image
Run two independent Gitea Actions runners on one host as rootless Podman A single container image that is the canonical build environment for trx-rs,
containers managed by systemd (Quadlet) — one per project — instead of two used **both** by CI and by developers. It bakes in the pinned Rust toolchain
VMs. Uses the **host executor**: workflow steps run directly inside a (matching `rust-toolchain.toml`) and every build dependency, so the compiler
purpose-built runner image that already has the Rust toolchain and all build and `clippy` are identical everywhere — no "works on my machine".
dependencies baked in, so CI runs skip the per-run install cost and no
Docker/Podman socket is needed.
## Files
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `Containerfile` | Runner image: Debian + build deps + clang + Rust + Node + `reuse` + `act_runner`. | | `Containerfile` | The SDK image (Debian + build deps + pinned Rust + Node + git). |
| `entrypoint.sh` | Registers on first boot (if needed), then runs the daemon. | | `runner-config.example.yaml` | Example act_runner config for the CI VM (Docker executor). |
| `config.yaml` | act_runner config template (seeded into each runner's volume). |
| `trx-rs-runner.container` | Quadlet unit for the trx-rs runner. |
| `project2-runner.container` | Quadlet unit for the second project's runner. |
## Prerequisites (once per host) ## Build and publish
Rootless Podman with cgroups v2 (default on modern distros). As the unprivileged
user that will own the runners:
```bash ```bash
# Survive logout / start on boot without an interactive session. # from the repo root
loginctl enable-linger "$USER" podman build -t git.haxx.space/sjg/trx-rs/sdk:latest container
podman login git.haxx.space
podman push git.haxx.space/sjg/trx-rs/sdk:latest
``` ```
No `podman.socket` is required for the host executor. Tag with the Rust version too (e.g. `:1.97.1`) if you want reproducible pins.
Make the package **public** (Gitea → Packages → the image → Settings) so the CI
runner and developers can pull it without credentials. If you keep it private,
add `credentials:` under the workflow's `container:` and log the runner into the
registry.
## 1. Build the image ## Developer use
Reproducible one-off build, no local toolchain needed:
```bash ```bash
cd container podman run --rm -it -v "$PWD":/work -w /work \
podman build -t trx-rs-ci:latest . git.haxx.space/sjg/trx-rs/sdk:latest \
cargo build --release
``` ```
## 2. Get a registration token Or open the repo in the image via VS Code / JetBrains "Reopen in Container"
(`.devcontainer/devcontainer.json` points at the same image).
For **each** repo: *Settings → Actions → Runners → Create new Runner* and copy Building outside the container? `rust-toolchain.toml` pins the same rustc, so
the token. (Org- or instance-level tokens work too if you prefer wider scope.) `rustup` installs the matching toolchain automatically.
## 3. Install and start the runners ## CI use
`.gitea/workflows/ci.yml` runs the `lint` and `test` jobs *inside* this image
via the `container:` key, so they skip all setup and go straight to `cargo`.
The `reuse` job stays on the upstream `fsfe/reuse-action` (a Docker action the
Docker executor launches as a sibling container) — nothing REUSE-related is
baked into the SDK.
## Compilation cache (sccache)
The SDK image ships [`sccache`](https://github.com/mozilla/sccache). It is
enabled via `RUSTC_WRAPPER=sccache` in CI and the devcontainer (not repo-wide,
so plain `cargo` builds outside the SDK are unaffected).
- **CI** persists the cache on the runner host — create the dir once:
`mkdir -p /var/cache/sccache`. It is bind-mounted into each job container at
`/sccache` (see `runner-config.example.yaml`), so cache survives across runs
and is shared between the lint/test jobs and both projects.
- **Devcontainer** uses a named volume (`trx-rs-sccache`).
- Check effectiveness with `sccache --show-stats` (the CI jobs print it).
`CARGO_INCREMENTAL=0` is set wherever sccache is on, since sccache cannot cache
incremental artifacts.
## CI runner (Alpine / OpenRC)
The runner uses the **Docker executor** (not the host executor): per-job
container isolation and standard `ubuntu-latest` semantics. `act_runner` runs
as an OpenRC service. Files provided:
| File | Purpose |
|------|---------|
| `act_runner.openrc` | OpenRC init script (`supervise-daemon`, depends on docker). |
| `act_runner.confd.example` | Per-instance `conf.d` settings for multi-runner hosts. |
**Cap the thread budget.** In a VM, pin its vCPUs to specific host threads
(libvirt/KVM):
```xml
<vcpu placement='static'>2</vcpu>
<cputune>
<vcpupin vcpu='0' cpuset='4'/>
<vcpupin vcpu='1' cpuset='5'/>
</cputune>
```
On bare metal, the `container.options: "--cpus=2"` and `capacity: 1` in
`runner-config.example.yaml` already bound each runner.
**Set it up:**
```bash ```bash
mkdir -p ~/.config/containers/systemd # 1. Docker + a dedicated user with socket access
cp trx-rs-runner.container project2-runner.container ~/.config/containers/systemd/ apk add docker docker-cli
rc-update add docker default && rc-service docker start
adduser -S -D -H -h /var/lib/act_runner act
addgroup act docker
# Paste each repo's token for the FIRST boot only: # 2. act_runner binary (static Go build, works on musl)
# Environment=GITEA_RUNNER_REGISTRATION_TOKEN=xxxx… curl -fsSL -o /usr/local/bin/act_runner \
$EDITOR ~/.config/containers/systemd/trx-rs-runner.container https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-amd64
$EDITOR ~/.config/containers/systemd/project2-runner.container chmod +x /usr/local/bin/act_runner
systemctl --user daemon-reload # 3. Config + register one runner per project (scope keeps their jobs apart)
systemctl --user start trx-rs-runner install -Dm644 container/runner-config.example.yaml /etc/act_runner/trx-rs.yaml
systemctl --user start project2-runner install -d -o act /var/lib/act_runner/trx-rs
su act -s /bin/sh -c 'cd /var/lib/act_runner/trx-rs && \
act_runner register --no-interactive \
--instance https://git.haxx.space --token <TOKEN> \
--name trx-rs-ci \
--labels "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"'
systemctl --user status trx-rs-runner # 4. OpenRC service (repeat the symlink+conf.d for the second project)
podman logs -f gitea-runner-trx-rs install -m755 container/act_runner.openrc /etc/init.d/act_runner
ln -s act_runner /etc/init.d/act_runner.trx-rs
install -m644 container/act_runner.confd.example /etc/conf.d/act_runner.trx-rs
rc-update add act_runner.trx-rs default
rc-service act_runner.trx-rs start
``` ```
Once each runner shows **online** in the repo's runner list, blank out the Check it with `rc-service act_runner.trx-rs status` and
`GITEA_RUNNER_REGISTRATION_TOKEN` line again (the registration is persisted in `tail -f /var/log/act_runner.trx-rs.log`.
the `…-data` volume) and `systemctl --user daemon-reload`.
## Required workflow change: the `reuse` job
The host executor runs steps directly in the container and therefore **cannot
run Docker-based actions**. The current `reuse` job uses `fsfe/reuse-action@v5`,
which is a Docker action. `reuse` is baked into the image, so replace that job
with a plain command:
```yaml
reuse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: REUSE compliance
run: reuse lint
```
The `lint` and `test` jobs need no changes: their `sudo apt-get …` and rustup
steps still run, but become fast no-ops because the image already has those
packages and the toolchain. (`sudo` is included in the image for exactly this
reason.)
> If you would rather keep Docker-based actions and per-run images, use the
> **Docker executor** instead: drop the `:host` suffix from the label in
> `config.yaml`, enable `systemctl --user --now enable podman.socket`, mount it
> into the container, and set `container.docker_host` to the socket path. That
> trades the baked-in speed for stronger per-job isolation.
## Tuning
- **`capacity`** (in `config.yaml`) — concurrent jobs per runner. Rust builds
are heavy; 12 is sensible when two runners share a host.
- **`PodmanArgs=--cpus/--memory`** (in each `.container`) — hard resource caps
so one project cannot starve the other.
- **SELinux** — the `:Z` volume flag is already set; keep it if SELinux is
enforcing.
## Committing these files
If you add this directory to a REUSE-checked repo, register the markdown in
`REUSE.toml` (the other files carry inline SPDX headers):
```toml
[[annotations]]
path = ["container/**"]
SPDX-FileCopyrightText = "2026 Stan Grams <sjg@haxx.space>"
SPDX-License-Identifier = "GPL-2.0-or-later"
```
+14
View File
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Per-instance settings for an act_runner OpenRC service.
# Copy to /etc/conf.d/<service-name>, e.g. /etc/conf.d/act_runner.trx-rs
# (the name must match the /etc/init.d/ symlink).
# User that runs the daemon. Must be a member of the `docker` group.
runner_user="act"
# Per-instance state dir (holds the .runner registration) and config file,
# so two runners on one host stay independent.
runner_dir="/var/lib/act_runner/trx-rs"
runner_config="/etc/act_runner/trx-rs.yaml"
+44
View File
@@ -0,0 +1,44 @@
#!/sbin/openrc-run
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
# SPDX-License-Identifier: GPL-2.0-or-later
#
# OpenRC service for a Gitea act_runner (Docker executor) on Alpine.
#
# Install as /etc/init.d/act_runner (chmod +x). Single instance uses
# /etc/act_runner/config.yaml. For one runner per project, symlink this script
# and add a matching conf.d file:
#
# ln -s act_runner /etc/init.d/act_runner.trx-rs
# cp container/act_runner.confd.example /etc/conf.d/act_runner.trx-rs
# $EDITOR /etc/conf.d/act_runner.trx-rs # set runner_dir / runner_config
# rc-update add act_runner.trx-rs default
# rc-service act_runner.trx-rs start
description="Gitea Actions runner"
: "${runner_user:=act}"
: "${runner_dir:=/var/lib/act_runner}"
: "${runner_config:=/etc/act_runner/config.yaml}"
command="/usr/local/bin/act_runner"
command_args="daemon --config ${runner_config}"
# No group given, so supplementary groups (incl. docker) are initialised.
command_user="${runner_user}"
directory="${runner_dir}"
supervisor="supervise-daemon"
respawn_delay=5
respawn_max=0
pidfile="/run/${RC_SVCNAME}.pid"
output_log="/var/log/${RC_SVCNAME}.log"
error_log="/var/log/${RC_SVCNAME}.log"
depend() {
need docker
use net dns
}
start_pre() {
checkpath -d -m 0750 -o "${runner_user}" "${runner_dir}"
checkpath -f -m 0640 -o "${runner_user}" "${output_log}"
}
-32
View File
@@ -1,32 +0,0 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# act_runner configuration template. Seeded into /data/config.yaml on first
# boot; edit the copy inside the volume to change settings per runner.
log:
level: info
runner:
# Registration state. Relative to the daemon's working directory (/data).
file: .runner
# Concurrent jobs this runner will pick up. Rust builds are heavy — keep this
# modest, especially if two runners share one host. The trx-rs workflow has
# three parallel jobs (lint, test, reuse); capacity 2 lets two overlap.
capacity: 2
timeout: 3h
# Map the workflow's `runs-on: ubuntu-latest` to the HOST executor, i.e. run
# steps directly inside THIS container (which already has all the toolchain).
# No Docker/Podman socket is required in this mode.
labels:
- "ubuntu-latest:host"
cache:
# Built-in actions cache server (used by actions/cache). Stored in the volume.
enabled: true
dir: "/data/cache"
host:
# Where per-job workspaces are created.
workdir_parent: /data/workflows
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Registers the runner on first boot (if no .runner state exists in /data),
# then runs the act_runner daemon. Idempotent: on subsequent boots it reuses
# the stored registration and ignores the token.
set -euo pipefail
CONFIG_FILE="${CONFIG_FILE:-/data/config.yaml}"
cd /data
# Seed the config from the image's template on first boot so it lives in the
# persistent volume and can be edited there.
if [ ! -f "$CONFIG_FILE" ]; then
cp /etc/act_runner/config.yaml "$CONFIG_FILE"
fi
# runner.file in config.yaml is ".runner" (relative to this CWD => /data/.runner).
if [ ! -f /data/.runner ]; then
if [ -z "${GITEA_RUNNER_REGISTRATION_TOKEN:-}" ]; then
echo "ERROR: no /data/.runner registration and GITEA_RUNNER_REGISTRATION_TOKEN is empty." >&2
echo " Grab a token from the repo's Settings -> Actions -> Runners and set it" >&2
echo " in the Quadlet unit for the first boot only." >&2
exit 1
fi
echo "Registering runner '${GITEA_RUNNER_NAME:-podman}' with ${GITEA_INSTANCE_URL} ..."
act_runner register --no-interactive \
--config "$CONFIG_FILE" \
--instance "${GITEA_INSTANCE_URL:?set GITEA_INSTANCE_URL}" \
--token "$GITEA_RUNNER_REGISTRATION_TOKEN" \
--name "${GITEA_RUNNER_NAME:-podman}" \
--labels "${GITEA_RUNNER_LABELS:-ubuntu-latest:host}"
fi
exec act_runner daemon --config "$CONFIG_FILE"
-40
View File
@@ -1,40 +0,0 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Rootless Podman Quadlet for the SECOND project's Gitea Actions runner,
# co-located on the same host as the trx-rs runner.
#
# It has its own name, its own data volume and its own registration token, so
# the two runners are fully independent. They share the `ubuntu-latest` label,
# but registration SCOPE (which repo each token came from) keeps their jobs
# separate — neither will pick up the other's work.
#
# If project 2 needs different build dependencies, build it its own image from
# an adjusted Containerfile and point Image= at that instead of reusing the
# trx-rs image below.
[Unit]
Description=Gitea Actions runner — project 2
After=network-online.target
Wants=network-online.target
[Container]
Image=localhost/gitea-act-runner:latest
ContainerName=gitea-runner-project2
Volume=gitea-runner-project2-data:/data:Z
Environment=CONFIG_FILE=/data/config.yaml
Environment=GITEA_INSTANCE_URL=https://git.haxx.space
Environment=GITEA_RUNNER_NAME=project2-podman
Environment=GITEA_RUNNER_LABELS=ubuntu-latest:host
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=
PodmanArgs=--cpus=4.0 --memory=6g
[Service]
Restart=always
TimeoutStartSec=0
[Install]
WantedBy=default.target
+35
View File
@@ -0,0 +1,35 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Example act_runner config for the Docker-executor runner that lives in the
# CI VM. This is NOT the SDK image — it configures the runner that launches
# per-job containers (including the trx-rs SDK image referenced by the
# workflow's `container:` key). Copy to the VM and pass with
# `act_runner daemon --config`.
log:
level: info
runner:
file: .runner
# One concurrent job. With one runner per project on a 2-vCPU VM this keeps
# total CI usage at ~2 threads.
capacity: 1
timeout: 3h
# Docker executor: no ":host" suffix. Maps runs-on labels to base images
# (the workflow overrides these per job via `container:`).
labels:
- "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
cache:
enabled: true
container:
# Cap every job container's CPU so CI stays within the 2-thread budget even
# if capacity is raised later. The -v mount persists the sccache cache on the
# host (create it first: `mkdir -p /var/cache/sccache`), matching SCCACHE_DIR
# in the workflow.
options: "--cpus=2 -v /var/cache/sccache:/sccache"
# Reuse the host VM's Docker network for the built-in cache/artifact server.
network: "host"
-41
View File
@@ -1,41 +0,0 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Rootless Podman Quadlet for the trx-rs Gitea Actions runner.
# Install to ~/.config/containers/systemd/trx-rs-runner.container then:
# systemctl --user daemon-reload
# systemctl --user start trx-rs-runner
#
# First boot only: paste a registration token (repo Settings -> Actions ->
# Runners) into GITEA_RUNNER_REGISTRATION_TOKEN. After the runner appears
# online you can blank it again — the registration is persisted in the volume.
[Unit]
Description=Gitea Actions runner — trx-rs
After=network-online.target
Wants=network-online.target
[Container]
Image=localhost/trx-rs-ci:latest
ContainerName=gitea-runner-trx-rs
# Persistent state: .runner registration, cache, workspaces.
Volume=gitea-runner-trx-rs-data:/data:Z
Environment=CONFIG_FILE=/data/config.yaml
Environment=GITEA_INSTANCE_URL=https://git.haxx.space
Environment=GITEA_RUNNER_NAME=trx-rs-podman
Environment=GITEA_RUNNER_LABELS=ubuntu-latest:host
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=
# Resource caps so a heavy Rust build here cannot starve the other project's
# runner on the same host. Tune to your box.
PodmanArgs=--cpus=4.0 --memory=6g
[Service]
Restart=always
# A cold Rust build can be slow; don't let systemd consider startup failed.
TimeoutStartSec=0
[Install]
WantedBy=default.target
+11
View File
@@ -0,0 +1,11 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Pins the Rust toolchain for reproducible builds. Keep in sync with the
# SDK image (container/Containerfile, ARG RUST_VERSION). rustup honours this
# automatically for local builds outside the SDK container.
[toolchain]
channel = "1.97.1"
components = ["rustfmt", "clippy"]
+1 -2
View File
@@ -274,8 +274,7 @@ mod tests {
// Pseudo-random noise vs gradient — correlation should be low. // Pseudo-random noise vs gradient — correlation should be low.
let noise: Vec<u8> = (0..256) let noise: Vec<u8> = (0..256)
.map(|i| (i as u32).wrapping_mul(1_103_515_245).wrapping_add(12_345)) .map(|i| ((i * 1103515245 + 12345) as u32 >> 8 & 0xff) as u8)
.map(|value| ((value >> 8) & 0xff) as u8)
.collect(); .collect();
let r = asm.correlation_with_last(&noise).expect("r"); let r = asm.correlation_with_last(&noise).expect("r");
assert!( assert!(
@@ -1624,8 +1624,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div> </div>
</div> </div>
<script defer src="https://cdn.jsdelivr.net/npm/opus-decoder@0.7.11/dist/opus-decoder.min.js" charset="UTF-8"></script> <script defer src="https://cdn.jsdelivr.net/npm/opus-decoder@0.7.11/dist/opus-decoder.min.js" charset="UTF-8"></script>
<script defer src="/vendor/leaflet.js"></script>
<script defer src="/leaflet-ais-tracksymbol.js"></script>
<script defer src="/webgl-renderer.js"></script> <script defer src="/webgl-renderer.js"></script>
<script defer src="/app.js"></script> <script defer src="/app.js"></script>
<script> <script>
@@ -1634,48 +1632,23 @@ SPDX-License-Identifier: GPL-2.0-or-later
var pluginScripts = { var pluginScripts = {
'digital-modes': ['/ft8.js', '/ft4.js', '/ft2.js', '/wspr.js', '/cw.js', '/background-decode.js', '/sat.js', '/wefax.js'], 'digital-modes': ['/ft8.js', '/ft4.js', '/ft2.js', '/wspr.js', '/cw.js', '/background-decode.js', '/sat.js', '/wefax.js'],
'map-data': ['/map-core.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js'], 'map-data': ['/map-core.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js'],
'map': ['/map-core.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js', '/sat.js', '/sat-scheduler.js'], 'map': ['/map-core.js', '/leaflet-ais-tracksymbol.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js', '/sat.js', '/sat-scheduler.js'],
'statistics': ['/map-core.js'], 'statistics': ['/map-core.js'],
'bookmarks': ['/bookmarks.js'], 'bookmarks': ['/bookmarks.js'],
'recorder': [], 'recorder': [],
'settings': ['/vchan.js', '/scheduler.js'] 'settings': ['/vchan.js', '/scheduler.js']
}; };
var loaded = new Set(); var loaded = new Set();
var loading = new Map();
function loadScript(src) {
if (loaded.has(src)) return Promise.resolve();
if (loading.has(src)) return loading.get(src);
var request = new Promise(function(resolve, reject) {
var s = document.createElement('script');
s.src = src;
s.onload = function() {
loaded.add(src);
loading.delete(src);
resolve();
};
s.onerror = function() {
loading.delete(src);
reject(new Error('Failed to load plugin script: ' + src));
};
document.body.appendChild(s);
});
loading.set(src, request);
return request;
}
function loadPlugins(tab) { function loadPlugins(tab) {
var scripts = pluginScripts[tab]; var scripts = pluginScripts[tab];
if (!scripts) return Promise.resolve(); if (!scripts) return;
return scripts.reduce(function(sequence, src) { scripts.forEach(function(src) {
return sequence.then(function() { return loadScript(src); }); if (loaded.has(src)) return;
}, Promise.resolve()); loaded.add(src);
} var s = document.createElement('script');
s.src = src;
function requestPlugins(tab) { s.defer = true;
return loadPlugins(tab).catch(function(err) { document.body.appendChild(s);
console.error(err);
}); });
} }
// Eager plugin loading is triggered by app.js (after window.trx is set up) // Eager plugin loading is triggered by app.js (after window.trx is set up)
@@ -1683,18 +1656,17 @@ SPDX-License-Identifier: GPL-2.0-or-later
// loading them before app.js would cause map-core.js to crash when // loading them before app.js would cause map-core.js to crash when
// window.trx is not yet defined. // window.trx is not yet defined.
window.loadEagerPlugins = function() { window.loadEagerPlugins = function() {
return Promise.all( ['digital-modes', 'map-data', 'bookmarks', 'settings'].forEach(loadPlugins);
['digital-modes', 'map-data', 'bookmarks', 'settings'].map(requestPlugins)
);
}; };
// Load others on tab switch // Load others on tab switch
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
var tab = e.target.closest('[data-tab]'); var tab = e.target.closest('[data-tab]');
if (tab) requestPlugins(tab.dataset.tab); if (tab) loadPlugins(tab.dataset.tab);
}); });
window.loadPluginsForTab = loadPlugins; window.loadPluginsForTab = loadPlugins;
})(); })();
</script> </script>
<!-- Template cloning is handled by navigateToTab() in app.js --> <!-- Template cloning is handled by navigateToTab() in app.js -->
<script defer src="/vendor/leaflet.js"></script>
</body> </body>
</html> </html>
+1 -18
View File
@@ -378,7 +378,7 @@ pub struct DecoderHistories {
pub lrpt: Mutex<VecDeque<(Instant, LrptImage)>>, pub lrpt: Mutex<VecDeque<(Instant, LrptImage)>>,
pub wefax: Mutex<VecDeque<(Instant, WefaxMessage)>>, pub wefax: Mutex<VecDeque<(Instant, WefaxMessage)>>,
/// Approximate total entry count across all decoders, maintained /// Approximate total entry count across all decoders, maintained
/// atomically so `estimated_total_count()` avoids 11 lock acquisitions. /// atomically so `estimated_total_count()` avoids 9 lock acquisitions.
total_count: AtomicUsize, total_count: AtomicUsize,
} }
@@ -845,23 +845,6 @@ impl DecoderHistories {
pub fn estimated_total_count(&self) -> usize { pub fn estimated_total_count(&self) -> usize {
self.total_count.load(Ordering::Relaxed) self.total_count.load(Ordering::Relaxed)
} }
/// Rebuild the aggregate count after bulk restoration bypasses the normal
/// record methods.
pub(crate) fn recalculate_total_count(&self) {
let total = lock_or_recover(&self.ais, "ais_history").len()
+ lock_or_recover(&self.vdes, "vdes_history").len()
+ lock_or_recover(&self.aprs, "aprs_history").len()
+ lock_or_recover(&self.hf_aprs, "hf_aprs_history").len()
+ lock_or_recover(&self.cw, "cw_history").len()
+ lock_or_recover(&self.ft8, "ft8_history").len()
+ lock_or_recover(&self.ft4, "ft4_history").len()
+ lock_or_recover(&self.ft2, "ft2_history").len()
+ lock_or_recover(&self.wspr, "wspr_history").len()
+ lock_or_recover(&self.lrpt, "lrpt_history").len()
+ lock_or_recover(&self.wefax, "wefax_history").len();
self.total_count.store(total, Ordering::Relaxed);
}
} }
/// Spawn the audio capture thread. /// Spawn the audio capture thread.
+5 -139
View File
@@ -19,7 +19,7 @@ use pickledb::{PickleDb, PickleDbDumpPolicy, SerializationMethod};
use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde::{de::DeserializeOwned, Deserialize, Serialize};
use trx_core::decode::{ use trx_core::decode::{
AisMessage, AprsPacket, CwEvent, Ft8Message, LrptImage, VdesMessage, WefaxMessage, WsprMessage, AisMessage, AprsPacket, CwEvent, Ft8Message, VdesMessage, WefaxMessage, WsprMessage,
}; };
use crate::audio::DecoderHistories; use crate::audio::DecoderHistories;
@@ -118,11 +118,6 @@ pub fn load_all(db: &PickleDb, rig_id: &str, histories: &Arc<DecoderHistories>)
h.push_back(e); h.push_back(e);
} }
} }
if let Ok(mut h) = histories.hf_aprs.lock() {
for e in load_key::<AprsPacket>(db, &k("hf_aprs")) {
h.push_back(e);
}
}
if let Ok(mut h) = histories.cw.lock() { if let Ok(mut h) = histories.cw.lock() {
for e in load_key::<CwEvent>(db, &k("cw")) { for e in load_key::<CwEvent>(db, &k("cw")) {
h.push_back(e); h.push_back(e);
@@ -133,32 +128,16 @@ pub fn load_all(db: &PickleDb, rig_id: &str, histories: &Arc<DecoderHistories>)
h.push_back(e); h.push_back(e);
} }
} }
if let Ok(mut h) = histories.ft4.lock() {
for e in load_key::<Ft8Message>(db, &k("ft4")) {
h.push_back(e);
}
}
if let Ok(mut h) = histories.ft2.lock() {
for e in load_key::<Ft8Message>(db, &k("ft2")) {
h.push_back(e);
}
}
if let Ok(mut h) = histories.wspr.lock() { if let Ok(mut h) = histories.wspr.lock() {
for e in load_key::<WsprMessage>(db, &k("wspr")) { for e in load_key::<WsprMessage>(db, &k("wspr")) {
h.push_back(e); h.push_back(e);
} }
} }
if let Ok(mut h) = histories.lrpt.lock() {
for e in load_key::<LrptImage>(db, &k("lrpt")) {
h.push_back(e);
}
}
if let Ok(mut h) = histories.wefax.lock() { if let Ok(mut h) = histories.wefax.lock() {
for e in load_key::<WefaxMessage>(db, &k("wefax")) { for e in load_key::<WefaxMessage>(db, &k("wefax")) {
h.push_back(e); h.push_back(e);
} }
} }
histories.recalculate_total_count();
} }
/// Flush `histories` to the database under `rig_id`-prefixed keys and sync. /// Flush `histories` to the database under `rig_id`-prefixed keys and sync.
@@ -183,11 +162,6 @@ pub fn flush_all(db: &mut PickleDb, rig_id: &str, histories: &Arc<DecoderHistori
drop(h); drop(h);
save_key(db, &k("aprs"), &snapshot); save_key(db, &k("aprs"), &snapshot);
} }
if let Ok(h) = histories.hf_aprs.lock() {
let snapshot = h.clone();
drop(h);
save_key(db, &k("hf_aprs"), &snapshot);
}
if let Ok(h) = histories.cw.lock() { if let Ok(h) = histories.cw.lock() {
let snapshot = h.clone(); let snapshot = h.clone();
drop(h); drop(h);
@@ -198,26 +172,11 @@ pub fn flush_all(db: &mut PickleDb, rig_id: &str, histories: &Arc<DecoderHistori
drop(h); drop(h);
save_key(db, &k("ft8"), &snapshot); save_key(db, &k("ft8"), &snapshot);
} }
if let Ok(h) = histories.ft4.lock() {
let snapshot = h.clone();
drop(h);
save_key(db, &k("ft4"), &snapshot);
}
if let Ok(h) = histories.ft2.lock() {
let snapshot = h.clone();
drop(h);
save_key(db, &k("ft2"), &snapshot);
}
if let Ok(h) = histories.wspr.lock() { if let Ok(h) = histories.wspr.lock() {
let snapshot = h.clone(); let snapshot = h.clone();
drop(h); drop(h);
save_key(db, &k("wspr"), &snapshot); save_key(db, &k("wspr"), &snapshot);
} }
if let Ok(h) = histories.lrpt.lock() {
let snapshot = h.clone();
drop(h);
save_key(db, &k("lrpt"), &snapshot);
}
if let Ok(h) = histories.wefax.lock() { if let Ok(h) = histories.wefax.lock() {
let snapshot = h.clone(); let snapshot = h.clone();
drop(h); drop(h);
@@ -226,38 +185,20 @@ pub fn flush_all(db: &mut PickleDb, rig_id: &str, histories: &Arc<DecoderHistori
let _ = db.dump(); let _ = db.dump();
} }
fn flush_all_rigs(db: &Mutex<PickleDb>, rig_histories: &[(String, Arc<DecoderHistories>)]) {
let Ok(mut guard) = db.lock() else {
tracing::warn!("history database mutex poisoned; skipping periodic flush");
return;
};
for (rig_id, histories) in rig_histories {
flush_all(&mut guard, rig_id, histories);
}
}
/// Spawn a Tokio task that flushes all rigs' histories to disk every 60 seconds. /// Spawn a Tokio task that flushes all rigs' histories to disk every 60 seconds.
///
/// Snapshot cloning, JSON serialization, and disk I/O run on Tokio's blocking
/// pool so a large history database cannot stall an async runtime worker.
pub fn spawn_flush_task( pub fn spawn_flush_task(
db: Arc<Mutex<PickleDb>>, db: Arc<Mutex<PickleDb>>,
rig_histories: Vec<(String, Arc<DecoderHistories>)>, rig_histories: Vec<(String, Arc<DecoderHistories>)>,
) { ) {
tokio::spawn(async move { tokio::spawn(async move {
let rig_histories = Arc::new(rig_histories);
let mut interval = tokio::time::interval(Duration::from_secs(60)); let mut interval = tokio::time::interval(Duration::from_secs(60));
interval.tick().await; // consume the immediate first tick interval.tick().await; // consume the immediate first tick
loop { loop {
interval.tick().await; interval.tick().await;
let db = Arc::clone(&db); if let Ok(mut guard) = db.lock() {
let rig_histories = Arc::clone(&rig_histories); for (rig_id, histories) in &rig_histories {
if let Err(err) = tokio::task::spawn_blocking(move || { flush_all(&mut guard, rig_id, histories);
flush_all_rigs(&db, rig_histories.as_slice()); }
})
.await
{
tracing::warn!(error = %err, "history flush worker failed");
} }
} }
}); });
@@ -361,79 +302,4 @@ mod tests {
let _ = std::fs::remove_file(&db_file); let _ = std::fs::remove_file(&db_file);
let _ = std::fs::remove_dir(&dir); let _ = std::fs::remove_dir(&dir);
} }
#[test]
fn flush_and_load_all_restores_previously_omitted_histories() {
let db_file = std::env::temp_dir().join(format!(
"trx_history_all_{}_{}.db",
std::process::id(),
now_unix_ms()
));
let mut db = PickleDb::new(
&db_file,
PickleDbDumpPolicy::DumpUponRequest,
SerializationMethod::Json,
);
let source = DecoderHistories::new();
let now = Instant::now();
source.hf_aprs.lock().unwrap().push_back((
now,
AprsPacket {
rig_id: Some("rig-a".into()),
ts_ms: Some(now_unix_ms()),
src_call: "TEST".into(),
dest_call: "APRS".into(),
path: String::new(),
info: "history".into(),
info_bytes: Vec::new(),
packet_type: "position".into(),
crc_ok: true,
lat: None,
lon: None,
symbol_table: None,
symbol_code: None,
},
));
for (queue, mode) in [(&source.ft4, "FT4"), (&source.ft2, "FT2")] {
queue.lock().unwrap().push_back((
now,
Ft8Message {
rig_id: Some("rig-a".into()),
ts_ms: now_unix_ms(),
snr_db: -10.0,
dt_s: 0.1,
freq_hz: 1_000.0,
message: mode.into(),
},
));
}
source.lrpt.lock().unwrap().push_back((
now,
LrptImage {
rig_id: Some("rig-a".into()),
pass_start_ms: now_unix_ms(),
pass_end_ms: now_unix_ms(),
mcu_count: 1,
path: "/tmp/lrpt.png".into(),
ts_ms: Some(now_unix_ms()),
satellite: None,
channels: None,
geo_bounds: None,
ground_track: None,
},
));
flush_all(&mut db, "rig-a", &source);
let restored = DecoderHistories::new();
load_all(&db, "rig-a", &restored);
assert_eq!(restored.hf_aprs.lock().unwrap().len(), 1);
assert_eq!(restored.ft4.lock().unwrap().len(), 1);
assert_eq!(restored.ft2.lock().unwrap().len(), 1);
assert_eq!(restored.lrpt.lock().unwrap().len(), 1);
assert_eq!(restored.estimated_total_count(), 4);
let _ = std::fs::remove_file(db_file);
}
} }
@@ -251,9 +251,9 @@ fn mul_freq_domain(buf: &mut [FftComplex<f32>], h_freq: &[FftComplex<f32>], scal
unsafe { unsafe {
mul_freq_domain_neon(buf, h_freq, scale); mul_freq_domain_neon(buf, h_freq, scale);
} }
return;
} }
#[cfg(not(target_arch = "aarch64"))]
mul_freq_domain_scalar(buf, h_freq, scale); mul_freq_domain_scalar(buf, h_freq, scale);
} }