├── .eslintrc.cjs ├── .github ├── renovate.json └── workflows │ ├── codeql-analysis.yml │ ├── dependency-review.yml │ └── tests.yml ├── .gitignore ├── .golangci.yml ├── .ignore ├── LICENSE ├── Makefile ├── README.md ├── backoff ├── backoff.go ├── backoff.pb.go ├── backoff.pb.ts ├── backoff.proto └── cbackoff │ ├── .gitignore │ ├── LICENSE │ ├── README.md │ ├── backoff.go │ ├── backoff_test.go │ ├── exponential.go │ ├── exponential_test.go │ ├── tries.go │ └── tries_test.go ├── broadcast ├── broadcast.go └── broadcast_test.go ├── bufio └── bufio.go ├── ccall ├── ccall.go └── ccall_test.go ├── ccontainer ├── ccontainer.go ├── ccontainer_test.go ├── watchable.go └── watchable_test.go ├── commonprefix ├── commonprefix.go └── commonprefix_test.go ├── conc ├── queue.go └── queue_test.go ├── cqueue ├── lifo.go └── lifo_test.go ├── csync ├── mutex.go ├── mutex_test.go ├── rwmutex.go └── rwmutex_test.go ├── debounce-fswatcher ├── debounce-fswatcher.go └── debounce-fswatcher_js.go ├── deps.go ├── doc └── WEB_TESTS.md ├── enabled ├── enabled.go ├── enabled.pb.go ├── enabled.pb.ts └── enabled.proto ├── exec └── exec.go ├── filter ├── filter.go ├── filter.pb.go ├── filter.pb.ts └── filter.proto ├── fsutil ├── copy-file.go └── fsutil.go ├── gitcmd └── gitcmd.go ├── gitroot └── gitroot.go ├── go.mod ├── go.sum ├── gotargets ├── generate.go ├── gotargets-generate.go ├── gotargets.gen.go └── gotargets.go ├── httplog ├── client.go ├── client_test.go ├── fetch │ ├── fetch.go │ ├── fetch_js.go │ └── fetch_test.go ├── server.go └── server_test.go ├── iocloser ├── read-closer.go └── write-closer.go ├── ioproxy ├── ioproxy.go └── ioproxy_test.go ├── ioseek └── reader-at-seeker.go ├── iosizer ├── iosizer.go └── iosizer_test.go ├── iowriter ├── callback.go └── callback_test.go ├── js ├── fetch │ ├── LICENSE │ ├── README.md │ ├── doc.go │ ├── enums.go │ ├── fetch.go │ ├── fetch_test.go │ └── header.go └── readable-stream │ ├── stream.go │ └── stream_test.go ├── keyed ├── keyed-opts.go ├── keyed-refcount.go ├── keyed.go ├── keyed_test.go ├── log-exited.go └── routine.go ├── linkedlist ├── linkedlist.go └── linkedlist_test.go ├── memo ├── memo.go └── memo_test.go ├── package.json ├── padding ├── padding.go └── padding_test.go ├── prng ├── prng.go ├── prng_test.go ├── reader.go └── reader_test.go ├── promise ├── container.go ├── container_test.go ├── like.go ├── once.go ├── once_test.go ├── promise.go └── promise_test.go ├── refcount ├── refcount.go └── refcount_test.go ├── result └── result.go ├── retry └── retry.go ├── routine ├── options.go ├── result.go ├── result_test.go ├── routine.go ├── routine_test.go ├── state.go └── state_test.go ├── scrub └── scrub.go ├── tsconfig.json ├── unique ├── keyedlist.go ├── keyedlist_test.go ├── keyedmap.go └── keyedmap_test.go ├── vmime └── vmime.go └── yarn.lock /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint', 'unused-imports'], 5 | extends: [ 6 | 'eslint:recommended', 7 | 'plugin:@typescript-eslint/recommended', 8 | 'plugin:react-hooks/recommended', 9 | 'prettier', 10 | ], 11 | parserOptions: { 12 | project: './tsconfig.json', 13 | }, 14 | rules: { 15 | '@typescript-eslint/explicit-module-boundary-types': 'off', 16 | '@typescript-eslint/no-non-null-assertion': 'off', 17 | }, 18 | ignorePatterns: [ 19 | "node_modules", 20 | "dist", 21 | "coverage", 22 | "bundle", 23 | "runtime", 24 | "vendor", 25 | ".eslintrc.js", 26 | "wasm_exec.js" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | ":semanticPrefixFixDepsChoreOthers", 5 | ":ignoreModulesAndTests", 6 | "group:all", 7 | "workarounds:all" 8 | ], 9 | "branchConcurrentLimit": 0, 10 | "packageRules": [ 11 | { 12 | "matchManagers": ["gomod"], 13 | "matchDepTypes": ["replace"], 14 | "enabled": false 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ "master" ] 9 | schedule: 10 | - cron: '41 13 * * 6' 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | analyze: 17 | name: Analyze 18 | runs-on: ubuntu-latest 19 | permissions: 20 | actions: read 21 | contents: read 22 | security-events: write 23 | 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | language: [ 'go', 'javascript' ] 28 | go: ['1.24'] 29 | node: [23.x] 30 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 31 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 32 | 33 | steps: 34 | - name: Checkout repository 35 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 36 | 37 | - name: Setup Go ${{ matrix.go }} 38 | uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 39 | with: 40 | go-version: ${{ matrix.go }} 41 | 42 | - name: Cache Go modules 43 | uses: actions/cache/restore@v4 44 | with: 45 | path: | 46 | ~/.cache/go-build 47 | ~/go/pkg/mod 48 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 49 | restore-keys: | 50 | ${{ runner.os }}-go- 51 | 52 | - name: Setup Node.JS ${{ matrix.node }} 53 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 54 | with: 55 | node-version: ${{ matrix.node }} 56 | cache: 'yarn' 57 | 58 | - name: Initialize CodeQL 59 | uses: github/codeql-action/init@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15 60 | with: 61 | languages: ${{ matrix.language }} 62 | 63 | 64 | - name: Autobuild 65 | uses: github/codeql-action/autobuild@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15 66 | 67 | - name: Perform CodeQL Analysis 68 | uses: github/codeql-action/analyze@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15 69 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. 4 | # 5 | # Source repository: https://github.com/actions/dependency-review-action 6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement 7 | name: 'Dependency Review' 8 | on: [pull_request] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | dependency-review: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: 'Checkout Repository' 18 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 19 | - name: 'Dependency Review' 20 | uses: actions/dependency-review-action@ce3cf9537a52e8119d91fd484ab5b8a807627bf8 # v4.6.0 21 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ "master" ] 9 | 10 | # Builds images for target boards. 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | tests: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | go: ['1.24'] 20 | node: [23.x] 21 | timeout-minutes: 10 22 | steps: 23 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 24 | 25 | - name: Setup Go ${{ matrix.go }} 26 | uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 27 | with: 28 | go-version: ${{ matrix.go }} 29 | 30 | - name: Setup Node.JS ${{ matrix.node }} 31 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 32 | with: 33 | node-version: ${{ matrix.node }} 34 | cache: 'yarn' 35 | 36 | - name: Yarn install 37 | run: yarn install 38 | 39 | - name: Cache tools 40 | uses: actions/cache@v4 41 | with: 42 | path: | 43 | ./.tools 44 | key: ${{ runner.os }}-aptre-tools-${{ hashFiles('**/go.sum') }} 45 | 46 | - name: Build Javascript 47 | run: yarn run build 48 | 49 | - name: Test Go 50 | run: make test 51 | 52 | - name: Test Js 53 | run: yarn test:js 54 | 55 | - name: Lint Js 56 | run: yarn run lint:js 57 | 58 | - name: Lint Go 59 | run: yarn run lint:go 60 | 61 | - name: Depcheck Js 62 | run: yarn run deps 63 | 64 | - name: Install chrome 65 | uses: browser-actions/setup-chrome@latest 66 | 67 | # Issue: https://github.com/agnivade/wasmbrowsertest/issues/60 68 | # Remove the || true once fixed. 69 | - name: Test with chrome 70 | run: make test-browser || true 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /coverage 3 | /build 4 | /.log 5 | /.snowpack 6 | 7 | # misc 8 | .DS_Store 9 | .env.local 10 | .env.development.local 11 | .env.test.local 12 | .env.production.local 13 | 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | 18 | .#* 19 | /dist 20 | .*.swp 21 | .vs/ 22 | .vscode/ 23 | !.vscode/launch.json 24 | 25 | vendor/ 26 | debug.test 27 | .aider* 28 | /.tools/ 29 | .env 30 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # Config file version V2 has been introduced, read more in lintersdb. 2 | # Visit https://golangci-lint.run/usage/configuration/#config-file 3 | version: "2" 4 | 5 | # Settings for the `run` command. 6 | run: 7 | # Concurrency defines how many analyses can run simultaneously. 8 | # Default is 1. 9 | concurrency: 4 10 | # Modules-download-mode specifies how to download modules. 11 | # Allowed values: readonly, vendor, mod. Default is readonly. 12 | modules-download-mode: vendor 13 | 14 | # Linters configuration. 15 | linters: 16 | enable: 17 | - depguard 18 | - gosec 19 | - importas 20 | - misspell 21 | - revive 22 | - unconvert 23 | disable: 24 | - errcheck 25 | settings: 26 | depguard: 27 | rules: 28 | main: 29 | deny: 30 | - pkg: io/ioutil 31 | desc: The io/ioutil package has been deprecated, see https://go.dev/doc/go1.16#ioutil 32 | - pkg: "github.com/stretchr/testify/assert" 33 | desc: Use "gotest.tools/v3/assert" instead 34 | - pkg: "github.com/stretchr/testify/require" 35 | desc: Use "gotest.tools/v3/assert" instead 36 | - pkg: "github.com/stretchr/testify/suite" 37 | desc: Do not use 38 | gosec: 39 | excludes: 40 | - G306 # Allow WriteFile permissions to be 0644. 41 | importas: 42 | # Do not allow unaliased imports of aliased packages. 43 | no-unaliased: true 44 | revive: 45 | rules: 46 | - name: package-comments 47 | disabled: true 48 | staticcheck: 49 | # All SA checks are enabled by default, customize as needed. 50 | # Refer to https://staticcheck.io/docs/checks for check details. 51 | checks: 52 | - all 53 | - '-SA1012' # Allow passing nil contexts. 54 | - '-ST1003' # Example of disabling another check if needed 55 | 56 | # Exclusions based on common patterns and presets. 57 | exclusions: 58 | # Treat generated files leniently. 59 | generated: lax 60 | # Use predefined sets of common exclusions. 61 | presets: 62 | - comments 63 | - common-false-positives 64 | - legacy # Excludes checks deprecated in new Go versions 65 | - std-error-handling # Excludes some common stdlib error patterns 66 | # Exclude specific paths using regex. 67 | paths: 68 | - third_party$ 69 | - builtin$ 70 | - examples$ 71 | 72 | # Issues reporting configuration. 73 | issues: 74 | # Maximum issues count per one linter. Set to 0 to disable. Default is 50. 75 | max-issues-per-linter: 0 76 | # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. 77 | max-same-issues: 0 78 | 79 | # Formatters configuration (new in V2). 80 | formatters: 81 | enable: 82 | - goimports # Enable goimports as a formatter. 83 | exclusions: 84 | # Treat generated files leniently for formatting. 85 | generated: lax 86 | # Exclude specific paths from formatting using regex. 87 | paths: 88 | - third_party$ 89 | - builtin$ 90 | - examples$ 91 | -------------------------------------------------------------------------------- /.ignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | go.sum 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-2025 Aperture Robotics, LLC. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # https://github.com/aperturerobotics/template 2 | PROJECT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) 3 | SHELL:=bash 4 | MAKEFLAGS += --no-print-directory 5 | 6 | GO_VENDOR_DIR := ./vendor 7 | COMMON_DIR := $(GO_VENDOR_DIR)/github.com/aperturerobotics/common 8 | COMMON_MAKEFILE := $(COMMON_DIR)/Makefile 9 | 10 | export GO111MODULE=on 11 | undefine GOARCH 12 | undefine GOOS 13 | 14 | .PHONY: $(MAKECMDGOALS) 15 | 16 | all: 17 | 18 | $(COMMON_MAKEFILE): vendor 19 | @if [ ! -f $(COMMON_MAKEFILE) ]; then \ 20 | echo "Please add github.com/aperturerobotics/common to your go.mod."; \ 21 | exit 1; \ 22 | fi 23 | 24 | $(MAKECMDGOALS): $(COMMON_MAKEFILE) 25 | @$(MAKE) -C $(COMMON_DIR) PROJECT_DIR="$(PROJECT_DIR)" $@ 26 | 27 | %: $(COMMON_MAKEFILE) 28 | @$(MAKE) -C $(COMMON_DIR) PROJECT_DIR="$(PROJECT_DIR)" $@ 29 | 30 | vendor: 31 | go mod vendor 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Utilities 2 | 3 | [![GoDoc Widget]][GoDoc] [![Go Report Card Widget]][Go Report Card] [![DeepWiki Widget]][DeepWiki] 4 | 5 | [GoDoc]: https://godoc.org/github.com/aperturerobotics/util 6 | [GoDoc Widget]: https://godoc.org/github.com/aperturerobotics/util?status.svg 7 | [Go Report Card Widget]: https://goreportcard.com/badge/github.com/aperturerobotics/util 8 | [Go Report Card]: https://goreportcard.com/report/github.com/aperturerobotics/util 9 | [DeepWiki Widget]: https://img.shields.io/badge/DeepWiki-aperturerobotics%2Futil-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg== 10 | [DeepWiki]: https://deepwiki.com/aperturerobotics/util 11 | 12 | Various utilities for Go and TypeScript including: 13 | 14 | - [backoff]: configurable backoff 15 | - [broadcast]: channel-based broadcast (similar to sync.Cond) 16 | - [ccall]: call a set of functions concurrently and wait for error or exit 17 | - [ccontainer]: concurrent container for objects 18 | - [commonprefix]: find common prefix between strings 19 | - [conc]: concurrent processing queue 20 | - [cqueue]: concurrent atomic queues (LIFO) 21 | - [csync]: sync primitives supporting context arguments 22 | - [debounce-fswatcher]: debounce fs watcher events 23 | - [enabled]: three-way boolean proto enum 24 | - [exec]: wrapper around Go os exec 25 | - [fsutil]: utilities for os filesystem 26 | - [gitroot]: git repository root finder 27 | - [httplog/fetch]: JS Fetch API wrapper with logging for WASM 28 | - [httplog]: HTTP request and response logging utilities 29 | - [iocloser]: wrap reader/writer with a close function 30 | - [iowriter]: io.Writer implementation with callback function 31 | - [iosizer]: read/writer with metrics for size 32 | - [js/fetch]: Fetch API wrapper for WASM 33 | - [js/readable-stream]: ReadableStream wrapper for WASM 34 | - [keyed]: key/value based routine management 35 | - [linkedlist]: linked list with head/tail 36 | - [memo]: memoize a function: call it once and remember results 37 | - [padding]: pad / unpad a byte array slice 38 | - [prng]: psuedorandom generator with seed 39 | - [promise]: promise mechanics for Go (like JS) 40 | - [refcount]: reference counter ccontainer 41 | - [routine]: start, stop, restart, reset a goroutine 42 | - [scrub]: zero a buffer after usage 43 | - [unique]: deduplicated list of items by key 44 | 45 | [backoff]: ./backoff 46 | [broadcast]: ./broadcast 47 | [ccall]: ./ccall 48 | [ccontainer]: ./ccontainer 49 | [commonprefix]: ./commonprefix 50 | [conc]: ./conc 51 | [cqueue]: ./cqueue 52 | [csync]: ./csync 53 | [debounce-fswatcher]: ./debounce-fswatcher 54 | [exec]: ./exec 55 | [fsutil]: ./fsutil 56 | [httplog/fetch]: ./httplog/fetch 57 | [httplog]: ./httplog 58 | [iocloser]: ./iocloser 59 | [iowriter]: ./iowriter 60 | [iosizer]: ./iosizer 61 | [js/fetch]: ./js/fetch 62 | [js/readable-stream]: ./js/readable-stream 63 | [keyed]: ./keyed 64 | [linkedlist]: ./linkedlist 65 | [memo]: ./memo 66 | [padding]: ./padding 67 | [prng]: ./prng 68 | [promise]: ./promise 69 | [refcount]: ./refcount 70 | [routine]: ./routine 71 | [scrub]: ./scrub 72 | [unique]: ./unique 73 | [vmime]: ./vmime 74 | [vmime]: ./vmime 75 | 76 | ## License 77 | 78 | MIT 79 | -------------------------------------------------------------------------------- /backoff/backoff.go: -------------------------------------------------------------------------------- 1 | package backoff 2 | 3 | import ( 4 | "time" 5 | 6 | backoff "github.com/aperturerobotics/util/backoff/cbackoff" 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | // Stop indicates that no more retries should be made for use in NextBackOff(). 11 | const Stop = backoff.Stop 12 | 13 | // GetEmpty returns if the backoff config is empty. 14 | func (b *Backoff) GetEmpty() bool { 15 | return b.GetBackoffKind() == 0 16 | } 17 | 18 | // Construct constructs the backoff. 19 | // Validates the options. 20 | func (b *Backoff) Construct() backoff.BackOff { 21 | switch b.GetBackoffKind() { 22 | default: 23 | fallthrough 24 | case BackoffKind_BackoffKind_EXPONENTIAL: 25 | return b.constructExpo() 26 | case BackoffKind_BackoffKind_CONSTANT: 27 | return b.constructConstant() 28 | } 29 | } 30 | 31 | // Validate validates the backoff kind. 32 | func (b BackoffKind) Validate() error { 33 | switch b { 34 | case BackoffKind_BackoffKind_UNKNOWN: 35 | case BackoffKind_BackoffKind_EXPONENTIAL: 36 | case BackoffKind_BackoffKind_CONSTANT: 37 | default: 38 | return errors.Errorf("unknown backoff kind: %s", b.String()) 39 | } 40 | return nil 41 | } 42 | 43 | // Validate validates the backoff config. 44 | func (b *Backoff) Validate(allowEmpty bool) error { 45 | if !allowEmpty && b.GetEmpty() { 46 | return errors.New("backoff must be set") 47 | } 48 | if err := b.GetBackoffKind().Validate(); err != nil { 49 | return err 50 | } 51 | return nil 52 | } 53 | 54 | // constructExpo constructs an exponential backoff. 55 | func (b *Backoff) constructExpo() backoff.BackOff { 56 | expo := backoff.NewExponentialBackOff() 57 | opts := b.GetExponential() 58 | 59 | initialInterval := opts.GetInitialInterval() 60 | if initialInterval == 0 { 61 | // default to 800ms 62 | initialInterval = 800 63 | } 64 | expo.InitialInterval = time.Duration(initialInterval) * time.Millisecond 65 | 66 | multiplier := opts.GetMultiplier() 67 | if multiplier == 0 { 68 | multiplier = 1.8 69 | } 70 | expo.Multiplier = float64(multiplier) 71 | 72 | maxInterval := opts.GetMaxInterval() 73 | if maxInterval == 0 { 74 | maxInterval = 20000 75 | } 76 | expo.MaxInterval = time.Duration(maxInterval) * time.Millisecond 77 | expo.RandomizationFactor = float64(opts.GetRandomizationFactor()) 78 | if opts.GetMaxElapsedTime() == 0 { 79 | expo.MaxElapsedTime = 0 80 | } else { 81 | expo.MaxElapsedTime = time.Duration(opts.GetMaxElapsedTime()) * time.Millisecond 82 | } 83 | expo.Reset() 84 | return expo 85 | } 86 | 87 | // constructConstant constructs a constant backoff. 88 | func (b *Backoff) constructConstant() backoff.BackOff { 89 | dur := b.GetConstant().GetInterval() 90 | if dur == 0 { 91 | dur = 5000 92 | } 93 | bo := backoff.NewConstantBackOff(time.Duration(dur) * time.Millisecond) 94 | bo.Reset() 95 | return bo 96 | } 97 | -------------------------------------------------------------------------------- /backoff/backoff.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package backoff; 3 | 4 | // BackoffKind is the kind of backoff. 5 | enum BackoffKind { 6 | // BackoffKind_UNKNOWN defaults to BackoffKind_EXPONENTIAL 7 | BackoffKind_UNKNOWN = 0; 8 | // BackoffKind_EXPONENTIAL is an exponential backoff. 9 | BackoffKind_EXPONENTIAL = 1; 10 | // BackoffKind_CONSTANT is a constant backoff. 11 | BackoffKind_CONSTANT = 2; 12 | } 13 | 14 | // Backoff configures a backoff. 15 | message Backoff { 16 | // BackoffKind is the kind of backoff. 17 | BackoffKind backoff_kind = 1; 18 | 19 | // Exponential is the arguments for an exponential backoff. 20 | Exponential exponential = 2; 21 | // Constant is the arugment for a constant backoff. 22 | Constant constant = 3; 23 | } 24 | 25 | // Exponential is the exponential arguments. 26 | message Exponential { 27 | // InitialInterval is the initial interval in milliseconds. 28 | // Default: 800ms. 29 | uint32 initial_interval = 1; 30 | // Multiplier is the timing multiplier. 31 | // Default: 1.8 32 | float multiplier = 2; 33 | // MaxInterval is the maximum timing interval in milliseconds. 34 | // Default: 20 seconds 35 | uint32 max_interval = 3; 36 | // RandomizationFactor is the randomization factor. 37 | // Should be from [0, 1] as a percentage of the retry interval. 38 | // 39 | // randomized interval = RetryInterval * (random value in range [1 - RandomizationFactor, 1 + RandomizationFactor]) 40 | // 41 | // Default: 0 (disabled) 42 | float randomization_factor = 4; 43 | // MaxElapsedTime if set specifies a maximum time for the backoff, in milliseconds. 44 | // After this time the backoff and attached process terminates. 45 | // May be empty, might be ignored. 46 | uint32 max_elapsed_time = 5; 47 | } 48 | 49 | // Constant contains constant backoff options. 50 | message Constant { 51 | // Interval is the timing to back off, in milliseconds. 52 | // Defaults to 5 seconds. 53 | uint32 interval = 1; 54 | } 55 | -------------------------------------------------------------------------------- /backoff/cbackoff/.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | 24 | # IDEs 25 | .idea/ 26 | .aider* 27 | -------------------------------------------------------------------------------- /backoff/cbackoff/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Christian Stewart 4 | Copyright (c) 2014 Cenk Altı 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | this software and associated documentation files (the "Software"), to deal in 8 | the Software without restriction, including without limitation the rights to 9 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 10 | the Software, and to permit persons to whom the Software is furnished to do so, 11 | subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 18 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 19 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 20 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 21 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /backoff/cbackoff/README.md: -------------------------------------------------------------------------------- 1 | # Exponential Backoff 2 | 3 | This is a fork of [cenkalti/backoff](https://github.com/cenkalti/backoff) v4.0, with some unused features removed. 4 | 5 | This package is a Go port of the exponential backoff algorithm from [Google's HTTP Client Library for Java][google-http-java-client]. 6 | 7 | [Exponential backoff][exponential backoff wiki] 8 | is an algorithm that uses feedback to multiplicatively decrease the rate of some process, 9 | in order to gradually find an acceptable rate. 10 | The retries exponentially increase and stop increasing when a certain threshold is met. 11 | -------------------------------------------------------------------------------- /backoff/cbackoff/backoff.go: -------------------------------------------------------------------------------- 1 | // Package backoff implements backoff algorithms for retrying operations. 2 | // 3 | // Use Retry function for retrying operations that may fail. 4 | // If Retry does not meet your needs, 5 | // copy/paste the function into your project and modify as you wish. 6 | // 7 | // There is also Ticker type similar to time.Ticker. 8 | // You can use it if you need to work with channels. 9 | // 10 | // See Examples section below for usage examples. 11 | package backoff 12 | 13 | import "time" 14 | 15 | // BackOff is a backoff policy for retrying an operation. 16 | type BackOff interface { 17 | // NextBackOff returns the duration to wait before retrying the operation, 18 | // or backoff. Stop to indicate that no more retries should be made. 19 | // 20 | // Example usage: 21 | // 22 | // duration := backoff.NextBackOff(); 23 | // if (duration == backoff.Stop) { 24 | // // Do not retry operation. 25 | // } else { 26 | // // Sleep for duration and retry operation. 27 | // } 28 | // 29 | NextBackOff() time.Duration 30 | 31 | // Reset to initial state. 32 | Reset() 33 | } 34 | 35 | // Stop indicates that no more retries should be made for use in NextBackOff(). 36 | const Stop time.Duration = -1 37 | 38 | // ZeroBackOff is a fixed backoff policy whose backoff time is always zero, 39 | // meaning that the operation is retried immediately without waiting, indefinitely. 40 | type ZeroBackOff struct{} 41 | 42 | func (b *ZeroBackOff) Reset() {} 43 | 44 | func (b *ZeroBackOff) NextBackOff() time.Duration { return 0 } 45 | 46 | // StopBackOff is a fixed backoff policy that always returns backoff.Stop for 47 | // NextBackOff(), meaning that the operation should never be retried. 48 | type StopBackOff struct{} 49 | 50 | func (b *StopBackOff) Reset() {} 51 | 52 | func (b *StopBackOff) NextBackOff() time.Duration { return Stop } 53 | 54 | // ConstantBackOff is a backoff policy that always returns the same backoff delay. 55 | // This is in contrast to an exponential backoff policy, 56 | // which returns a delay that grows longer as you call NextBackOff() over and over again. 57 | type ConstantBackOff struct { 58 | Interval time.Duration 59 | } 60 | 61 | func (b *ConstantBackOff) Reset() {} 62 | func (b *ConstantBackOff) NextBackOff() time.Duration { return b.Interval } 63 | 64 | func NewConstantBackOff(d time.Duration) *ConstantBackOff { 65 | return &ConstantBackOff{Interval: d} 66 | } 67 | -------------------------------------------------------------------------------- /backoff/cbackoff/backoff_test.go: -------------------------------------------------------------------------------- 1 | package backoff 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestNextBackOffMillis(t *testing.T) { 9 | subtestNextBackOff(t, 0, new(ZeroBackOff)) 10 | subtestNextBackOff(t, Stop, new(StopBackOff)) 11 | } 12 | 13 | func subtestNextBackOff(t *testing.T, expectedValue time.Duration, backOffPolicy BackOff) { 14 | for i := 0; i < 10; i++ { 15 | next := backOffPolicy.NextBackOff() 16 | if next != expectedValue { 17 | t.Errorf("got: %d expected: %d", next, expectedValue) 18 | } 19 | } 20 | } 21 | 22 | func TestConstantBackOff(t *testing.T) { 23 | backoff := NewConstantBackOff(time.Second) 24 | if backoff.NextBackOff() != time.Second { 25 | t.Error("invalid interval") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /backoff/cbackoff/tries.go: -------------------------------------------------------------------------------- 1 | package backoff 2 | 3 | import "time" 4 | 5 | /* 6 | WithMaxRetries creates a wrapper around another BackOff, which will 7 | return Stop if NextBackOff() has been called too many times since 8 | the last time Reset() was called 9 | 10 | Note: Implementation is not thread-safe. 11 | */ 12 | func WithMaxRetries(b BackOff, max uint64) BackOff { 13 | return &backOffTries{delegate: b, maxTries: max} 14 | } 15 | 16 | type backOffTries struct { 17 | delegate BackOff 18 | maxTries uint64 19 | numTries uint64 20 | } 21 | 22 | func (b *backOffTries) NextBackOff() time.Duration { 23 | if b.maxTries == 0 { 24 | return Stop 25 | } 26 | if b.maxTries > 0 { 27 | if b.maxTries <= b.numTries { 28 | return Stop 29 | } 30 | b.numTries++ 31 | } 32 | return b.delegate.NextBackOff() 33 | } 34 | 35 | func (b *backOffTries) Reset() { 36 | b.numTries = 0 37 | b.delegate.Reset() 38 | } 39 | -------------------------------------------------------------------------------- /backoff/cbackoff/tries_test.go: -------------------------------------------------------------------------------- 1 | package backoff 2 | 3 | import ( 4 | "math/rand" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestMaxTries(t *testing.T) { 10 | r := rand.New(rand.NewSource(time.Now().UnixNano())) //nolint:gosec 11 | max := 17 + r.Intn(13) 12 | bo := WithMaxRetries(&ZeroBackOff{}, uint64(max)) //nolint:gosec 13 | 14 | // Load up the tries count, but reset should clear the record 15 | for range max / 2 { 16 | bo.NextBackOff() 17 | } 18 | bo.Reset() 19 | 20 | // Now fill the tries count all the way up 21 | for ix := range max { 22 | d := bo.NextBackOff() 23 | if d == Stop { 24 | t.Errorf("returned Stop on try %d", ix) 25 | } 26 | } 27 | 28 | // We have now called the BackOff max number of times, we expect 29 | // the next result to be Stop, even if we try it multiple times 30 | for range 7 { 31 | d := bo.NextBackOff() 32 | if d != Stop { 33 | t.Error("invalid next back off") 34 | } 35 | } 36 | 37 | // Reset makes it all work again 38 | bo.Reset() 39 | d := bo.NextBackOff() 40 | if d == Stop { 41 | t.Error("returned Stop after reset") 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /broadcast/broadcast.go: -------------------------------------------------------------------------------- 1 | package broadcast 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "sync" 7 | ) 8 | 9 | // Broadcast implements notifying waiters via a channel. 10 | // 11 | // The zero-value of this struct is valid. 12 | type Broadcast struct { 13 | mtx sync.Mutex 14 | ch chan struct{} 15 | } 16 | 17 | // HoldLock locks the mutex and calls the callback. 18 | // 19 | // broadcast closes the wait channel, if any. 20 | // getWaitCh returns a channel that will be closed when broadcast is called. 21 | func (c *Broadcast) HoldLock(cb func(broadcast func(), getWaitCh func() <-chan struct{})) { 22 | c.mtx.Lock() 23 | defer c.mtx.Unlock() 24 | cb(c.broadcastLocked, c.getWaitChLocked) 25 | } 26 | 27 | // TryHoldLock attempts to lock the mutex and call the callback. 28 | // It returns true if the lock was acquired and the callback was called, false otherwise. 29 | func (c *Broadcast) TryHoldLock(cb func(broadcast func(), getWaitCh func() <-chan struct{})) bool { 30 | if !c.mtx.TryLock() { 31 | return false 32 | } 33 | defer c.mtx.Unlock() 34 | cb(c.broadcastLocked, c.getWaitChLocked) 35 | return true 36 | } 37 | 38 | // HoldLockMaybeAsync locks the mutex and calls the callback if possible. 39 | // If the mutex cannot be locked right now, starts a new Goroutine to wait for it. 40 | func (c *Broadcast) HoldLockMaybeAsync(cb func(broadcast func(), getWaitCh func() <-chan struct{})) { 41 | holdBroadcastLock := func(lock bool) { 42 | if lock { 43 | c.mtx.Lock() 44 | } 45 | // use defer to catch panic cases 46 | defer c.mtx.Unlock() 47 | cb(c.broadcastLocked, c.getWaitChLocked) 48 | } 49 | 50 | // fast path: lock immediately 51 | if c.mtx.TryLock() { 52 | holdBroadcastLock(false) 53 | } else { 54 | // slow path: use separate goroutine 55 | go holdBroadcastLock(true) 56 | } 57 | } 58 | 59 | // Wait waits for the cb to return true or an error before returning. 60 | // When the broadcast channel is broadcasted, re-calls cb again to re-check the value. 61 | // cb is called while the mutex is locked. 62 | // Returns context.Canceled if ctx is canceled. 63 | func (c *Broadcast) Wait(ctx context.Context, cb func(broadcast func(), getWaitCh func() <-chan struct{}) (bool, error)) error { 64 | if cb == nil || ctx == nil { 65 | return errors.New("cb and ctx must be set") 66 | } 67 | 68 | var waitCh <-chan struct{} 69 | 70 | for { 71 | if ctx.Err() != nil { 72 | return context.Canceled 73 | } 74 | 75 | var done bool 76 | var err error 77 | c.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 78 | done, err = cb(broadcast, getWaitCh) 79 | if !done && err == nil { 80 | waitCh = getWaitCh() 81 | } 82 | }) 83 | 84 | if done || err != nil { 85 | return err 86 | } 87 | 88 | select { 89 | case <-ctx.Done(): 90 | return context.Canceled 91 | case <-waitCh: 92 | } 93 | } 94 | } 95 | 96 | // broadcastLocked is the implementation of Broadcast while mtx is locked. 97 | func (c *Broadcast) broadcastLocked() { 98 | if c.ch != nil { 99 | close(c.ch) 100 | c.ch = nil 101 | } 102 | } 103 | 104 | // getWaitChLocked is the implementation of GetWaitCh while mtx is locked. 105 | func (c *Broadcast) getWaitChLocked() <-chan struct{} { 106 | if c.ch == nil { 107 | c.ch = make(chan struct{}) 108 | } 109 | return c.ch 110 | } 111 | -------------------------------------------------------------------------------- /broadcast/broadcast_test.go: -------------------------------------------------------------------------------- 1 | package broadcast 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | func ExampleBroadcast() { 10 | // b guards currValue 11 | var b Broadcast 12 | var currValue int 13 | 14 | go func() { 15 | // 0 to 9 inclusive 16 | for i := range 10 { 17 | <-time.After(time.Millisecond * 20) 18 | b.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 19 | currValue = i 20 | broadcast() 21 | }) 22 | } 23 | }() 24 | 25 | var waitCh <-chan struct{} 26 | var gotValue int 27 | for { 28 | b.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 29 | gotValue = currValue 30 | waitCh = getWaitCh() 31 | }) 32 | 33 | // last value 34 | if gotValue == 9 { 35 | // success 36 | break 37 | } 38 | 39 | // otherwise keep waiting 40 | <-waitCh 41 | } 42 | 43 | fmt.Printf("waited for value to increment: %v\n", gotValue) 44 | // Output: waited for value to increment: 9 45 | } 46 | 47 | func ExampleBroadcast_Wait() { 48 | // b guards currValue 49 | var b Broadcast 50 | var currValue int 51 | 52 | go func() { 53 | // 0 to 9 inclusive 54 | for i := range 10 { 55 | <-time.After(time.Millisecond * 20) 56 | b.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 57 | currValue = i 58 | broadcast() 59 | }) 60 | } 61 | }() 62 | 63 | ctx := context.Background() 64 | var gotValue int 65 | err := b.Wait(ctx, func(broadcast func(), getWaitCh func() <-chan struct{}) (bool, error) { 66 | gotValue = currValue 67 | return gotValue == 9, nil 68 | }) 69 | if err != nil { 70 | fmt.Printf("failed to wait for value: %v", err.Error()) 71 | return 72 | } 73 | 74 | fmt.Printf("waited for value to increment: %v\n", gotValue) 75 | // Output: waited for value to increment: 9 76 | } 77 | -------------------------------------------------------------------------------- /bufio/bufio.go: -------------------------------------------------------------------------------- 1 | package util_bufio 2 | 3 | // SplitOnNul is a bufio.SplitFunc that splits on NUL characters. 4 | func SplitOnNul(data []byte, atEOF bool) (advance int, token []byte, err error) { 5 | for i := 0; i < len(data); i++ { 6 | if data[i] == '\x00' { 7 | return i + 1, data[:i], nil 8 | } 9 | } 10 | return 0, nil, nil 11 | } 12 | -------------------------------------------------------------------------------- /ccall/ccall.go: -------------------------------------------------------------------------------- 1 | package ccall 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/aperturerobotics/util/broadcast" 7 | ) 8 | 9 | // CallConcurrentlyFunc is a function passed to CallConcurrently. 10 | type CallConcurrentlyFunc = func(ctx context.Context) error 11 | 12 | // CallConcurrently calls multiple functions concurrently and waits for exit or error. 13 | func CallConcurrently(ctx context.Context, fns ...CallConcurrentlyFunc) error { 14 | if len(fns) == 0 { 15 | return nil 16 | } 17 | 18 | subCtx, subCtxCancel := context.WithCancel(ctx) 19 | defer subCtxCancel() 20 | if len(fns) == 1 { 21 | return fns[0](subCtx) 22 | } 23 | 24 | var bcast broadcast.Broadcast 25 | var running int 26 | var exitErr error 27 | 28 | callFunc := func(fn CallConcurrentlyFunc) { 29 | err := fn(subCtx) 30 | bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 31 | running-- 32 | if err != nil && (exitErr == nil || exitErr == context.Canceled) { 33 | exitErr = err 34 | } 35 | broadcast() 36 | }) 37 | } 38 | 39 | var waitCh <-chan struct{} 40 | bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 41 | waitCh = getWaitCh() 42 | for _, fn := range fns { 43 | if fn == nil { 44 | continue 45 | } 46 | running++ 47 | go callFunc(fn) 48 | } 49 | }) 50 | if running == 0 { 51 | return nil 52 | } 53 | 54 | for { 55 | select { 56 | case <-ctx.Done(): 57 | return context.Canceled 58 | case <-waitCh: 59 | } 60 | 61 | var currRunning int 62 | var currExitErr error 63 | bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 64 | currRunning = running 65 | currExitErr = exitErr 66 | waitCh = getWaitCh() 67 | }) 68 | if currRunning == 0 || (currExitErr != nil && currExitErr != context.Canceled) { 69 | return currExitErr 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /ccall/ccall_test.go: -------------------------------------------------------------------------------- 1 | package ccall 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "sync/atomic" 7 | "testing" 8 | ) 9 | 10 | // TestCallConcurrently_Success tests calling multiple functions concurrently successfully. 11 | func TestCallConcurrently_Success(t *testing.T) { 12 | var accum atomic.Int32 13 | 14 | var fns []CallConcurrentlyFunc 15 | for i := int32(0); i < 10; i++ { 16 | x := i // copy value 17 | fns = append(fns, func(ctx context.Context) error { 18 | accum.Add(x) 19 | return nil 20 | }) 21 | } 22 | 23 | if err := CallConcurrently(context.Background(), fns...); err != nil { 24 | t.Fatal(err.Error()) 25 | } 26 | 27 | if val := accum.Load(); val != 45 { 28 | t.Fatalf("expected 45 but got %d", val) 29 | } 30 | } 31 | 32 | // TestCallConcurrently_Err tests calling multiple functions with an error. 33 | func TestCallConcurrently_Err(t *testing.T) { 34 | errRet := errors.New("test error") 35 | 36 | var fns []CallConcurrentlyFunc 37 | for i := 0; i < 10; i++ { 38 | i := i 39 | fns = append(fns, func(ctx context.Context) error { 40 | if i == 5 || i == 8 { 41 | return errRet 42 | } 43 | return nil 44 | }) 45 | } 46 | 47 | if err := CallConcurrently(context.Background(), fns...); err != errRet { 48 | t.Fatalf("expected error but got %v", err) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /ccontainer/ccontainer.go: -------------------------------------------------------------------------------- 1 | package ccontainer 2 | 3 | import ( 4 | "context" 5 | 6 | proto "github.com/aperturerobotics/protobuf-go-lite" 7 | "github.com/aperturerobotics/util/broadcast" 8 | ) 9 | 10 | // CContainer is a concurrent container. 11 | type CContainer[T comparable] struct { 12 | bcast broadcast.Broadcast 13 | val T 14 | equal func(a, b T) bool 15 | } 16 | 17 | // NewCContainer builds a CContainer with an initial value. 18 | func NewCContainer[T comparable](val T) *CContainer[T] { 19 | return &CContainer[T]{val: val} 20 | } 21 | 22 | // NewCContainerWithEqual builds a CContainer with an initial value and a comparator. 23 | func NewCContainerWithEqual[T comparable](val T, isEqual func(a, b T) bool) *CContainer[T] { 24 | return &CContainer[T]{val: val, equal: isEqual} 25 | } 26 | 27 | // NewCContainerVT constructs a CContainer that uses VTEqual to check for equality. 28 | func NewCContainerVT[T proto.EqualVT[T]](val T) *CContainer[T] { 29 | return NewCContainerWithEqual(val, proto.CompareEqualVT[T]()) 30 | } 31 | 32 | // GetValue returns the immediate value of the container. 33 | func (c *CContainer[T]) GetValue() T { 34 | var val T 35 | c.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 36 | val = c.val 37 | }) 38 | return val 39 | } 40 | 41 | // SetValue sets the ccontainer value. 42 | func (c *CContainer[T]) SetValue(val T) { 43 | c.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 44 | if !c.compare(c.val, val) { 45 | c.val = val 46 | broadcast() 47 | } 48 | }) 49 | } 50 | 51 | // SwapValue locks the container, calls the callback, and stores the return value. 52 | // 53 | // Returns the updated value. 54 | // If cb is nil returns the current value without changes. 55 | func (c *CContainer[T]) SwapValue(cb func(val T) T) T { 56 | var val T 57 | c.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 58 | val = c.val 59 | if cb != nil { 60 | val = cb(val) 61 | if !c.compare(c.val, val) { 62 | c.val = val 63 | broadcast() 64 | } 65 | } 66 | }) 67 | return val 68 | } 69 | 70 | // WaitValueWithValidator waits for any value that matches the validator in the container. 71 | // errCh is an optional channel to read an error from. 72 | func (c *CContainer[T]) WaitValueWithValidator( 73 | ctx context.Context, 74 | valid func(v T) (bool, error), 75 | errCh <-chan error, 76 | ) (T, error) { 77 | var ok bool 78 | var err error 79 | var emptyValue T 80 | for { 81 | var val T 82 | var wake <-chan struct{} 83 | c.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 84 | val = c.val 85 | wake = getWaitCh() 86 | }) 87 | if valid != nil { 88 | ok, err = valid(val) 89 | } else { 90 | ok = !c.compare(val, emptyValue) 91 | err = nil 92 | } 93 | if err != nil { 94 | return emptyValue, err 95 | } 96 | if ok { 97 | return val, nil 98 | } 99 | 100 | select { 101 | case <-ctx.Done(): 102 | return emptyValue, ctx.Err() 103 | case err, ok := <-errCh: 104 | if !ok { 105 | // errCh was non-nil but was closed 106 | // treat this as context canceled 107 | return emptyValue, context.Canceled 108 | } 109 | if err != nil { 110 | return emptyValue, err 111 | } 112 | case <-wake: 113 | // woken, value changed 114 | } 115 | } 116 | } 117 | 118 | // WaitValue waits for any non-nil value in the container. 119 | // errCh is an optional channel to read an error from. 120 | func (c *CContainer[T]) WaitValue(ctx context.Context, errCh <-chan error) (T, error) { 121 | return c.WaitValueWithValidator(ctx, func(v T) (bool, error) { 122 | var emptyValue T 123 | return !c.compare(emptyValue, v), nil 124 | }, errCh) 125 | } 126 | 127 | // WaitValueChange waits for a value that is different than the given. 128 | // errCh is an optional channel to read an error from. 129 | func (c *CContainer[T]) WaitValueChange(ctx context.Context, old T, errCh <-chan error) (T, error) { 130 | return c.WaitValueWithValidator(ctx, func(v T) (bool, error) { 131 | return !c.compare(old, v), nil 132 | }, errCh) 133 | } 134 | 135 | // WaitValueEmpty waits for an empty value. 136 | // errCh is an optional channel to read an error from. 137 | func (c *CContainer[T]) WaitValueEmpty(ctx context.Context, errCh <-chan error) error { 138 | _, err := c.WaitValueWithValidator(ctx, func(v T) (bool, error) { 139 | var emptyValue T 140 | return c.compare(emptyValue, v), nil 141 | }, errCh) 142 | return err 143 | } 144 | 145 | // compare checks of two values are equal 146 | func (c *CContainer[T]) compare(a, b T) bool { 147 | if a == b { 148 | return true 149 | } 150 | if c.equal != nil && c.equal(a, b) { 151 | return true 152 | } 153 | return false 154 | } 155 | 156 | // _ is a type assertion 157 | var _ Watchable[struct{}] = ((*CContainer[struct{}])(nil)) 158 | -------------------------------------------------------------------------------- /ccontainer/ccontainer_test.go: -------------------------------------------------------------------------------- 1 | package ccontainer 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | // TestCContainer tests the concurrent container 10 | func TestCContainer(t *testing.T) { 11 | ctx := context.Background() 12 | c := NewCContainer[*int](nil) 13 | 14 | errCh := make(chan error, 1) 15 | _ = c.WaitValueEmpty(ctx, errCh) // should be instant 16 | 17 | val := 5 18 | go c.SetValue(&val) 19 | gv, err := c.WaitValue(ctx, errCh) 20 | if err != nil { 21 | t.Fatal(err.Error()) 22 | } 23 | if gv == nil || *gv != 5 { 24 | t.Fail() 25 | } 26 | 27 | dl, dlCancel := context.WithDeadline(ctx, time.Now().Add(time.Millisecond*1)) 28 | defer dlCancel() 29 | err = c.WaitValueEmpty(dl, errCh) 30 | if err != context.DeadlineExceeded { 31 | t.Fail() 32 | } 33 | 34 | c.SetValue(nil) 35 | _ = c.WaitValueEmpty(ctx, errCh) // should be instant 36 | 37 | swapPlusOne := func(val *int) *int { 38 | nv := 1 39 | if val != nil { 40 | nv = *val + 1 41 | } 42 | return &nv 43 | } 44 | 45 | for i := 1; i < 10; i++ { 46 | out := c.SwapValue(swapPlusOne) 47 | if out == nil || *out != i { 48 | t.Fail() 49 | } 50 | } 51 | } 52 | 53 | // TestCContainerWithEqual tests the concurrent container with an equal checker 54 | func TestCContainerWithEqual(t *testing.T) { 55 | type data struct { 56 | value string 57 | } 58 | 59 | ctx := context.Background() 60 | c := NewCContainerWithEqual[*data](nil, func(a, b *data) bool { 61 | if (a == nil) != (b == nil) { 62 | return false 63 | } 64 | if b.value == "same" { 65 | return true 66 | } 67 | return a.value == b.value 68 | }) 69 | 70 | mkInitial := func() *data { 71 | return &data{value: "hello"} 72 | } 73 | c.SetValue(mkInitial()) 74 | 75 | var done chan struct{} 76 | start := func() { 77 | done = make(chan struct{}) 78 | go func() { 79 | _, _ = c.WaitValueChange(ctx, mkInitial(), nil) 80 | close(done) 81 | }() 82 | } 83 | start() 84 | assertDone := func() { 85 | select { 86 | case <-done: 87 | case <-time.After(time.Millisecond * 100): 88 | t.Fatal("expected WaitValueChange to have returned") 89 | } 90 | } 91 | assertNotDone := func() { 92 | select { 93 | case <-done: 94 | t.Fatal("expected WaitValueChange to not return yet") 95 | case <-time.After(time.Millisecond * 50): 96 | } 97 | } 98 | assertNotDone() 99 | c.SetValue(mkInitial()) 100 | assertNotDone() 101 | c.SetValue(&data{value: "same"}) 102 | assertNotDone() 103 | c.SetValue(&data{value: "different"}) 104 | assertDone() 105 | start() 106 | assertDone() 107 | c.SetValue(mkInitial()) 108 | start() 109 | assertNotDone() 110 | c.SetValue(&data{value: "different"}) 111 | assertDone() 112 | } 113 | -------------------------------------------------------------------------------- /ccontainer/watchable.go: -------------------------------------------------------------------------------- 1 | package ccontainer 2 | 3 | import "context" 4 | 5 | // Watchable is an interface implemented by ccontainer for watching a value. 6 | type Watchable[T comparable] interface { 7 | // GetValue returns the current value. 8 | GetValue() T 9 | // WaitValueWithValidator waits for any value that matches the validator in the container. 10 | // errCh is an optional channel to read an error from. 11 | WaitValueWithValidator( 12 | ctx context.Context, 13 | valid func(v T) (bool, error), 14 | errCh <-chan error, 15 | ) (T, error) 16 | 17 | // WaitValue waits for any non-nil value in the container. 18 | // errCh is an optional channel to read an error from. 19 | WaitValue(ctx context.Context, errCh <-chan error) (T, error) 20 | // WaitValueChange waits for a value that is different than the given. 21 | // errCh is an optional channel to read an error from. 22 | WaitValueChange(ctx context.Context, old T, errCh <-chan error) (T, error) 23 | // WaitValueEmpty waits for an empty value. 24 | // errCh is an optional channel to read an error from. 25 | WaitValueEmpty(ctx context.Context, errCh <-chan error) error 26 | } 27 | 28 | // ToWatchable converts a ccontainer to a Watchable (somewhat read-only). 29 | func ToWatchable[T comparable](ctr *CContainer[T]) Watchable[T] { 30 | return ctr 31 | } 32 | 33 | // WatchChanges watches a Watchable and calls the callback when the value 34 | // changes. Note: the value pointer must change to trigger an update. 35 | // 36 | // initial is the initial value to wait for changes on. 37 | // set initial to nil to wait for value != nil. 38 | // 39 | // T is the type of the message. 40 | // errCh is an optional error channel to interrupt the operation. 41 | func WatchChanges[T comparable]( 42 | ctx context.Context, 43 | initialVal T, 44 | ctr Watchable[T], 45 | updateCb func(msg T) error, 46 | errCh <-chan error, 47 | ) error { 48 | // watch for changes 49 | current := initialVal 50 | for { 51 | next, err := ctr.WaitValueChange(ctx, current, errCh) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | current = next 57 | if err := updateCb(next); err != nil { 58 | return err 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /ccontainer/watchable_test.go: -------------------------------------------------------------------------------- 1 | package ccontainer 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | // TestWatchable tests the watchable ccontainer 9 | func TestWatchable(t *testing.T) { 10 | ctx := context.Background() 11 | c := NewCContainer[*int](nil) 12 | 13 | errCh := make(chan error, 1) 14 | w := ToWatchable(c) 15 | _ = w.WaitValueEmpty(ctx, errCh) // should be instant 16 | 17 | val := 5 18 | go c.SetValue(&val) 19 | gv, err := w.WaitValue(ctx, errCh) 20 | if err != nil { 21 | t.Fatal(err.Error()) 22 | } 23 | if gv == nil || *gv != 5 { 24 | t.Fail() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /commonprefix/commonprefix.go: -------------------------------------------------------------------------------- 1 | package commonprefix 2 | 3 | import "strings" 4 | 5 | // TrimPrefix removes the longest common prefix from all provided strings 6 | func TrimPrefix(strs ...string) { 7 | p := Prefix(strs...) 8 | if p == "" { 9 | return 10 | } 11 | for i, s := range strs { 12 | strs[i] = strings.TrimPrefix(s, p) 13 | } 14 | } 15 | 16 | // Prefix returns the longest common prefix of the provided strings 17 | // https://leetcode.com/problems/longest-common-prefix/discuss/374737/golang-runtime-0ms-simple-solution 18 | func Prefix(strs ...string) string { 19 | if len(strs) == 0 { 20 | return "" 21 | } 22 | // Find word with minimum length 23 | short := strs[0] 24 | for _, s := range strs { 25 | if len(short) >= len(s) { 26 | short = s 27 | } 28 | } 29 | prefx_array := []string{} 30 | prefix := "" 31 | old_prefix := "" 32 | for i := 0; i < len(short); i++ { 33 | prefx_array = append(prefx_array, string(short[i])) 34 | prefix = strings.Join(prefx_array, "") 35 | for _, s := range strs { 36 | if !strings.HasPrefix(s, prefix) { 37 | return old_prefix 38 | } 39 | } 40 | old_prefix = prefix 41 | } 42 | return prefix 43 | } 44 | -------------------------------------------------------------------------------- /commonprefix/commonprefix_test.go: -------------------------------------------------------------------------------- 1 | package commonprefix 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func doTest(t *testing.T, lines, pre, suf string) { 9 | strs := []string{} 10 | if lines != "" { 11 | strs = strings.Split(lines, "\n") 12 | } 13 | p := Prefix(strs...) 14 | if p != pre { 15 | t.Fatalf("fail: expected prefix '%s', got '%s'", pre, p) 16 | } 17 | } 18 | 19 | func TestXFix1(t *testing.T) { 20 | doTest(t, ``, "", "") 21 | } 22 | 23 | func TestXFix2(t *testing.T) { 24 | doTest(t, `single`, "single", "single") 25 | } 26 | 27 | func TestXFix3(t *testing.T) { 28 | doTest(t, "single\ndouble", "", "le") 29 | } 30 | 31 | func TestXFix4(t *testing.T) { 32 | doTest(t, "flower\nflow\nfleet", "fl", "") 33 | } 34 | 35 | func TestXFix5(t *testing.T) { 36 | doTest(t, `My Awesome Album - 01.mp3 37 | My Awesome Album - 11.mp3 38 | My Awesome Album - 03.mp3 39 | My Awesome Album - 04.mp3 40 | My Awesome Album - 05.mp3 41 | My Awesome Album - 06.mp3 42 | My Awesome Album - 07.mp3 43 | My Awesome Album - 08.mp3 44 | My Awesome Album - 09.mp3 45 | My Awesome Album - 10.mp3 46 | My Awesome Album - 11.mp3 47 | My Awesome Album - 12.mp3 48 | My Awesome Album - 13.mp3 49 | My Awesome Album - 14.mp3 50 | My Awesome Album - 15.mp3 51 | My Awesome Album - 16.mp3 52 | My Awesome Album - 17.mp3 53 | My Awesome Album - 18.mp3 54 | My Awesome Album - 19.mp3 55 | My Awesome Album - 20.mp3 56 | My Awesome Album - 21.mp3 57 | My Awesome Album - 22.mp3 58 | My Awesome Album - 23.mp3 59 | My Awesome Album - 24.mp3 60 | My Awesome Album - 25.mp3 61 | My Awesome Album - 26.mp3 62 | My Awesome Album - 27.mp3 63 | My Awesome Album - 28.mp3 64 | My Awesome Album - 29.mp3 65 | My Awesome Album - 30.mp3 66 | My Awesome Album - 31.mp3 67 | My Awesome Album - 32.mp3 68 | My Awesome Album - 33.mp3 69 | My Awesome Album - 34.mp3 70 | My Awesome Album - 35.mp3 71 | My Awesome Album - 36.mp3 72 | My Awesome Album - 37.mp3 73 | My Awesome Album - 38.mp3 74 | My Awesome Album - 39.mp3`, "My Awesome Album - ", ".mp3") 75 | } 76 | 77 | func TestTrimPrefix1(t *testing.T) { 78 | strs := []string{"flower", "flow", "fleet"} 79 | TrimPrefix(strs...) 80 | if strs[0] != "ower" { 81 | t.Fatalf("fail: expected result string to be 'ower', got '%s'", strs[0]) 82 | } 83 | } 84 | 85 | func TestTrimPrefix2(t *testing.T) { 86 | strs := []string{"flower", "tree"} 87 | TrimPrefix(strs...) // no common prefix 88 | if strs[0] != "flower" { 89 | t.Fatalf("fail: expected result string to be 'flower', got '%s'", strs[0]) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /conc/queue.go: -------------------------------------------------------------------------------- 1 | package conc 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/aperturerobotics/util/broadcast" 7 | "github.com/aperturerobotics/util/linkedlist" 8 | ) 9 | 10 | // ConcurrentQueue is a pool of goroutines processing a stream of jobs. 11 | // Job callbacks are called in the order they are added. 12 | type ConcurrentQueue struct { 13 | // bcast guards below fields 14 | bcast broadcast.Broadcast 15 | // maxConcurrency is the concurrency limit or 0 if none 16 | maxConcurrency int 17 | // running is the number of running goroutines. 18 | running int 19 | // jobQueue is the job queue linked list. 20 | jobQueue *linkedlist.LinkedList[func()] 21 | // jobQueueSize is the current size of jobQueue 22 | jobQueueSize int 23 | } 24 | 25 | // NewConcurrentQueue constructs a new stream concurrency manager. 26 | // initialElems contains the initial set of queued entries. 27 | // if maxConcurrency <= 0, spawns infinite goroutines. 28 | func NewConcurrentQueue(maxConcurrency int, initialElems ...func()) *ConcurrentQueue { 29 | str := &ConcurrentQueue{ 30 | jobQueue: linkedlist.NewLinkedList(initialElems...), 31 | jobQueueSize: len(initialElems), 32 | maxConcurrency: maxConcurrency, 33 | } 34 | if len(initialElems) != 0 { 35 | str.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 36 | str.updateLocked(broadcast) 37 | }) 38 | } 39 | return str 40 | } 41 | 42 | // Enqueue enqueues a job callback to the stream. 43 | // If possible, the job is started immediately and skips the queue. 44 | // Returns the current number of queued and running jobs. 45 | func (s *ConcurrentQueue) Enqueue(jobs ...func()) (queued, running int) { 46 | s.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 47 | if len(jobs) != 0 { 48 | for _, job := range jobs { 49 | if s.maxConcurrency <= 0 || s.running < s.maxConcurrency { 50 | s.running++ 51 | go s.executeJob(job) 52 | } else { 53 | s.jobQueueSize++ 54 | s.jobQueue.Push(job) 55 | } 56 | } 57 | broadcast() 58 | } 59 | 60 | queued, running = s.jobQueueSize, s.running 61 | }) 62 | 63 | return queued, running 64 | } 65 | 66 | // WaitIdle waits for no jobs to be running. 67 | // Returns context.Canceled if ctx is canceled. 68 | // errCh is an optional error channel. 69 | func (s *ConcurrentQueue) WaitIdle(ctx context.Context, errCh <-chan error) error { 70 | for { 71 | var idle bool 72 | var wait <-chan struct{} 73 | s.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 74 | idle = s.running == 0 && s.jobQueueSize == 0 75 | if !idle { 76 | wait = getWaitCh() 77 | } 78 | }) 79 | if idle { 80 | return nil 81 | } 82 | select { 83 | case <-ctx.Done(): 84 | return context.Canceled 85 | case err, ok := <-errCh: 86 | if !ok { 87 | // errCh was non-nil but was closed 88 | // treat this as context canceled 89 | return context.Canceled 90 | } 91 | if err != nil { 92 | return err 93 | } 94 | case <-wait: 95 | } 96 | } 97 | } 98 | 99 | // WatchState watches the concurrent queue state. 100 | // If the callback returns an error or false, returns that error or nil. 101 | // Returns nil immediately if callback is nil. 102 | // Returns context.Canceled if ctx is canceled. 103 | // errCh is an optional error channel. 104 | func (s *ConcurrentQueue) WatchState( 105 | ctx context.Context, 106 | errCh <-chan error, 107 | cb func(queued, running int) (bool, error), 108 | ) error { 109 | if cb == nil { 110 | return nil 111 | } 112 | 113 | for { 114 | var queued, running int 115 | var waitCh <-chan struct{} 116 | s.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 117 | queued, running = s.jobQueueSize, s.running 118 | waitCh = getWaitCh() 119 | }) 120 | 121 | cntu, err := cb(queued, running) 122 | if err != nil || !cntu { 123 | return err 124 | } 125 | 126 | select { 127 | case <-ctx.Done(): 128 | return context.Canceled 129 | case <-waitCh: 130 | } 131 | } 132 | } 133 | 134 | // updateLocked checks if we need to spawn any new routines. 135 | // caller must hold mtx 136 | func (s *ConcurrentQueue) updateLocked(broadcast func()) { 137 | var dirty bool 138 | for s.maxConcurrency <= 0 || s.running < s.maxConcurrency { 139 | job, jobOk := s.jobQueue.Pop() 140 | if !jobOk { 141 | break 142 | } 143 | s.jobQueueSize-- 144 | s.running++ 145 | dirty = true 146 | go s.executeJob(job) 147 | } 148 | if dirty { 149 | broadcast() 150 | } 151 | } 152 | 153 | // executeJob is a goroutine to execute a job function. 154 | // will continue to run until there are no more jobs. 155 | func (s *ConcurrentQueue) executeJob(job func()) { 156 | for { 157 | if job != nil { 158 | job() 159 | } 160 | 161 | var jobOk bool 162 | s.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 163 | job, jobOk = s.jobQueue.Pop() 164 | if !jobOk { 165 | s.running-- 166 | broadcast() 167 | } else { 168 | s.jobQueueSize-- 169 | } 170 | }) 171 | if !jobOk { 172 | return 173 | } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /conc/queue_test.go: -------------------------------------------------------------------------------- 1 | package conc 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | // TestConcurrentQueue tests the concurrent queue type. 8 | func TestConcurrentQueue(t *testing.T) { 9 | complete := make(chan struct{}) 10 | jobs := make(map[int]chan struct{}) 11 | n := 0 12 | mkJob := func() func() { 13 | doneCh := make(chan struct{}) 14 | jobs[n] = doneCh 15 | n++ 16 | return func() { 17 | close(doneCh) 18 | <-complete 19 | } 20 | } 21 | q := NewConcurrentQueue(2, mkJob()) 22 | queued, running := q.Enqueue(mkJob(), mkJob(), mkJob(), mkJob()) 23 | if queued != 3 || running != 2 { 24 | t.FailNow() 25 | } 26 | 27 | // expect 0 + 1 to complete immediately 28 | <-jobs[0] 29 | <-jobs[1] 30 | 31 | // expect 2 + 3 + 4 to not be started yet 32 | for i := 2; i <= 4; i++ { 33 | select { 34 | case <-jobs[i]: 35 | t.Fail() 36 | default: 37 | } 38 | } 39 | 40 | close(complete) 41 | 42 | // expect 2 + 3 + 4 to complete 43 | <-jobs[2] 44 | <-jobs[3] 45 | <-jobs[4] 46 | } 47 | -------------------------------------------------------------------------------- /cqueue/lifo.go: -------------------------------------------------------------------------------- 1 | package cqueue 2 | 3 | import ( 4 | "sync/atomic" 5 | ) 6 | 7 | // atomicLIFONode represents a single element in the LIFO. 8 | type atomicLIFONode[T any] struct { 9 | value T 10 | next *atomicLIFONode[T] 11 | } 12 | 13 | // AtomicLIFO implements an atomic last-in-first-out linked-list. 14 | type AtomicLIFO[T any] struct { 15 | top atomic.Pointer[atomicLIFONode[T]] 16 | } 17 | 18 | // Push atomically adds a value to the top of the LIFO. 19 | func (q *AtomicLIFO[T]) Push(value T) { 20 | newNode := &atomicLIFONode[T]{value: value} 21 | 22 | for { 23 | // Read the current top. 24 | oldTop := q.top.Load() 25 | 26 | // Set the next of the new atomicLIFONode to the current top. 27 | newNode.next = oldTop 28 | 29 | // Try to set the new atomicLIFONode as the new top. 30 | if q.top.CompareAndSwap(oldTop, newNode) { 31 | break 32 | } 33 | } 34 | } 35 | 36 | // Pop atomically removes and returns the top value of the LIFO. 37 | // It returns the zero value (nil) if the LIFO is empty. 38 | func (q *AtomicLIFO[T]) Pop() T { 39 | for { 40 | // Read the current top. 41 | oldTop := q.top.Load() 42 | if oldTop == nil { 43 | var empty T 44 | return empty 45 | } 46 | 47 | // Read the next atomicLIFONode after the top. 48 | next := oldTop.next 49 | 50 | // Try to set the next atomicLIFONode as the new top. 51 | if q.top.CompareAndSwap(oldTop, next) { 52 | return oldTop.value 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /cqueue/lifo_test.go: -------------------------------------------------------------------------------- 1 | package cqueue 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestAtomicLIFO_PushPopSingleGoroutine(t *testing.T) { 8 | lifo := &AtomicLIFO[int]{} 9 | 10 | lifo.Push(1) 11 | lifo.Push(2) 12 | lifo.Push(3) 13 | 14 | if v := lifo.Pop(); v != 3 { 15 | t.Fatalf("expected 3, got %v", v) 16 | } 17 | if v := lifo.Pop(); v != 2 { 18 | t.Fatalf("expected 2, got %v", v) 19 | } 20 | if v := lifo.Pop(); v != 1 { 21 | t.Fatalf("expected 1, got %v", v) 22 | } 23 | if v := lifo.Pop(); v != 0 { 24 | t.Fatalf("expected nil, got %v", v) 25 | } 26 | } 27 | 28 | func TestAtomicLIFO_ConsecutivePushesAndPops(t *testing.T) { 29 | lifo := &AtomicLIFO[int]{} 30 | const count = 1000 31 | 32 | for i := 0; i < count; i++ { 33 | lifo.Push(i) 34 | } 35 | 36 | for i := count - 1; i >= 0; i-- { 37 | if v := lifo.Pop(); v != i { 38 | t.Fatalf("expected %d, got %v", i, v) 39 | } 40 | } 41 | 42 | if v := lifo.Pop(); v != 0 { 43 | t.Fatalf("expected nil, got %v", v) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /csync/mutex.go: -------------------------------------------------------------------------------- 1 | package csync 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "sync/atomic" 7 | 8 | "github.com/aperturerobotics/util/broadcast" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | // Mutex implements a mutex with a Broadcast. 13 | // Implements a mutex that accepts a Context. 14 | // An empty value Mutex{} is valid. 15 | type Mutex struct { 16 | // bcast is broadcast when below fields change 17 | bcast broadcast.Broadcast 18 | // locked indicates the mutex is locked 19 | locked bool 20 | } 21 | 22 | // Lock attempts to hold a lock on the Mutex. 23 | // Returns a lock release function or an error. 24 | func (m *Mutex) Lock(ctx context.Context) (func(), error) { 25 | // status: 26 | // 0: waiting for lock 27 | // 1: locked 28 | // 2: unlocked (released) 29 | var status atomic.Int32 30 | var waitCh <-chan struct{} 31 | m.bcast.HoldLock(func(_ func(), getWaitCh func() <-chan struct{}) { 32 | if m.locked { 33 | // keep waiting 34 | waitCh = getWaitCh() 35 | } else { 36 | // 0: waiting for lock 37 | // 1: have the lock 38 | swapped := status.CompareAndSwap(0, 1) 39 | if swapped { 40 | m.locked = true 41 | } 42 | } 43 | }) 44 | 45 | release := func() { 46 | pre := status.Swap(2) 47 | // 1: we have the lock 48 | if pre != 1 { 49 | return 50 | } 51 | 52 | // unlock 53 | m.bcast.HoldLock(func(broadcast func(), _ func() <-chan struct{}) { 54 | m.locked = false 55 | broadcast() 56 | }) 57 | } 58 | 59 | // fast path: we locked the mutex 60 | if status.Load() == 1 { 61 | return release, nil 62 | } 63 | 64 | // slow path: watch for changes 65 | for { 66 | select { 67 | case <-ctx.Done(): 68 | release() 69 | return nil, context.Canceled 70 | case <-waitCh: 71 | } 72 | 73 | m.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 74 | // keep waiting for the lock 75 | if m.locked { 76 | waitCh = getWaitCh() 77 | return 78 | } 79 | 80 | // 0: waiting for lock 81 | // 1: have the lock 82 | swapped := status.CompareAndSwap(0, 1) 83 | if swapped { 84 | m.locked = true 85 | } 86 | }) 87 | 88 | nstatus := status.Load() 89 | switch nstatus { 90 | case 1: 91 | return release, nil 92 | case 2: 93 | return nil, context.Canceled 94 | } 95 | } 96 | } 97 | 98 | // TryLock attempts to hold a lock on the Mutex. 99 | // Returns a lock release function or nil if the lock could not be grabbed. 100 | func (m *Mutex) TryLock() (func(), bool) { 101 | var unlocked atomic.Bool 102 | m.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 103 | if m.locked { 104 | unlocked.Store(true) 105 | } else { 106 | m.locked = true 107 | } 108 | }) 109 | 110 | // we failed to lock the mutex 111 | if unlocked.Load() { 112 | return nil, false 113 | } 114 | 115 | return func() { 116 | if unlocked.Swap(true) { 117 | return 118 | } 119 | 120 | m.bcast.HoldLock(func(broadcast func(), _ func() <-chan struct{}) { 121 | m.locked = false 122 | broadcast() 123 | }) 124 | }, true 125 | } 126 | 127 | // Locker returns a MutexLocker that uses context.Background to lock the Mutex. 128 | func (m *Mutex) Locker() sync.Locker { 129 | return &MutexLocker{m: m} 130 | } 131 | 132 | // MutexLocker implements Locker for a Mutex. 133 | type MutexLocker struct { 134 | m *Mutex 135 | rel atomic.Pointer[func()] 136 | } 137 | 138 | // Lock implements the sync.Locker interface. 139 | func (l *MutexLocker) Lock() { 140 | release, err := l.m.Lock(context.Background()) 141 | if err != nil { 142 | panic(errors.Wrap(err, "csync: failed MutexLocker Lock")) 143 | } 144 | l.rel.Store(&release) 145 | } 146 | 147 | // Unlock implements the sync.Locker interface. 148 | func (l *MutexLocker) Unlock() { 149 | rel := l.rel.Swap(nil) 150 | if rel == nil { 151 | panic("csync: unlock of unlocked MutexLocker") 152 | } 153 | (*rel)() 154 | } 155 | 156 | // _ is a type assertion 157 | var _ sync.Locker = ((*MutexLocker)(nil)) 158 | -------------------------------------------------------------------------------- /csync/mutex_test.go: -------------------------------------------------------------------------------- 1 | package csync 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "runtime" 8 | "testing" 9 | ) 10 | 11 | // adapted from src/sync/mutex_test.go in Go 12 | // GOMAXPROCS=10 go test 13 | 14 | func HammerMutex(m *Mutex, loops int, cdone chan bool) { 15 | for i := 0; i < loops; i++ { 16 | release, err := m.Lock(context.Background()) 17 | if err != nil { 18 | panic(err) 19 | } 20 | release() 21 | } 22 | cdone <- true 23 | } 24 | 25 | func TestMutex(t *testing.T) { 26 | if n := runtime.SetMutexProfileFraction(1); n != 0 { 27 | t.Logf("got mutexrate %d expected 0", n) 28 | } 29 | defer runtime.SetMutexProfileFraction(0) 30 | 31 | m := new(Mutex) 32 | 33 | release, err := m.Lock(context.Background()) 34 | if err != nil { 35 | t.Fatalf("Lock failed: %v", err) 36 | } 37 | _, ok := m.TryLock() 38 | if ok { 39 | t.Fatalf("TryLock succeeded with mutex locked") 40 | } 41 | release() 42 | release2, ok := m.TryLock() 43 | if !ok { 44 | t.Fatalf("TryLock failed with mutex unlocked") 45 | } 46 | release2() 47 | 48 | c := make(chan bool) 49 | for i := 0; i < 10; i++ { 50 | go HammerMutex(m, 1000, c) 51 | } 52 | for i := 0; i < 10; i++ { 53 | <-c 54 | } 55 | } 56 | 57 | var misuseTests = []struct { 58 | name string 59 | f func() 60 | }{ 61 | { 62 | "Mutex.Unlock", 63 | func() { 64 | var mu Mutex 65 | mu.Locker().Unlock() 66 | }, 67 | }, 68 | { 69 | "Mutex.Unlock2", 70 | func() { 71 | var mu Mutex 72 | release, _ := mu.Lock(context.Background()) 73 | mu.Locker().Unlock() 74 | release() 75 | }, 76 | }, 77 | } 78 | 79 | func init() { 80 | if len(os.Args) == 3 && os.Args[1] == "TESTMISUSE" { 81 | for _, test := range misuseTests { 82 | if test.name == os.Args[2] { 83 | func() { 84 | defer func() { recover() }() 85 | test.f() 86 | }() 87 | fmt.Printf("test completed\n") 88 | os.Exit(0) 89 | } 90 | } 91 | fmt.Printf("unknown test\n") 92 | os.Exit(0) 93 | } 94 | } 95 | 96 | func BenchmarkMutexUncontended(b *testing.B) { 97 | type PaddedMutex struct { 98 | Mutex 99 | pad [128]uint8 //nolint:unused 100 | } 101 | b.RunParallel(func(pb *testing.PB) { 102 | var mu PaddedMutex 103 | for pb.Next() { 104 | release, err := mu.Lock(context.Background()) 105 | if err != nil { 106 | b.Fatalf("Lock failed: %v", err) 107 | } 108 | release() 109 | } 110 | }) 111 | } 112 | 113 | func benchmarkMutex(b *testing.B, slack, work bool) { 114 | var mu Mutex 115 | if slack { 116 | b.SetParallelism(10) 117 | } 118 | b.RunParallel(func(pb *testing.PB) { 119 | foo := 0 120 | for pb.Next() { 121 | release, err := mu.Lock(context.Background()) 122 | if err != nil { 123 | b.Fatalf("Lock failed: %v", err) 124 | } 125 | release() 126 | if work { 127 | for i := 0; i < 100; i++ { 128 | foo *= 2 129 | foo /= 2 130 | } 131 | } 132 | } 133 | _ = foo 134 | }) 135 | } 136 | 137 | func BenchmarkMutex(b *testing.B) { 138 | benchmarkMutex(b, false, false) 139 | } 140 | 141 | func BenchmarkMutexSlack(b *testing.B) { 142 | benchmarkMutex(b, true, false) 143 | } 144 | 145 | func BenchmarkMutexWork(b *testing.B) { 146 | benchmarkMutex(b, false, true) 147 | } 148 | 149 | func BenchmarkMutexWorkSlack(b *testing.B) { 150 | benchmarkMutex(b, true, true) 151 | } 152 | 153 | func BenchmarkMutexNoSpin(b *testing.B) { 154 | // This benchmark models a situation where spinning in the mutex should be 155 | // non-profitable and allows to confirm that spinning does not do harm. 156 | // To achieve this we create excess of goroutines most of which do local work. 157 | // These goroutines yield during local work, so that switching from 158 | // a blocked goroutine to other goroutines is profitable. 159 | // As a matter of fact, this benchmark still triggers some spinning in the mutex. 160 | var m Mutex 161 | var acc0, acc1 uint64 162 | b.SetParallelism(4) 163 | b.RunParallel(func(pb *testing.PB) { 164 | c := make(chan bool) 165 | var data [4 << 10]uint64 166 | for i := 0; pb.Next(); i++ { 167 | if i%4 == 0 { 168 | release, err := m.Lock(context.Background()) 169 | if err != nil { 170 | b.Fatalf("Lock failed: %v", err) 171 | } 172 | acc0 -= 100 173 | acc1 += 100 174 | release() 175 | } else { 176 | for i := 0; i < len(data); i += 4 { 177 | data[i]++ 178 | } 179 | // Elaborate way to say runtime.Gosched 180 | // that does not put the goroutine onto global runq. 181 | go func() { 182 | c <- true 183 | }() 184 | <-c 185 | } 186 | } 187 | }) 188 | } 189 | 190 | func BenchmarkMutexSpin(b *testing.B) { 191 | // This benchmark models a situation where spinning in the mutex should be 192 | // profitable. To achieve this we create a goroutine per-proc. 193 | // These goroutines access considerable amount of local data so that 194 | // unnecessary rescheduling is penalized by cache misses. 195 | var m Mutex 196 | var acc0, acc1 uint64 197 | b.RunParallel(func(pb *testing.PB) { 198 | var data [16 << 10]uint64 199 | for i := 0; pb.Next(); i++ { 200 | release, err := m.Lock(context.Background()) 201 | if err != nil { 202 | b.Fatalf("Lock failed: %v", err) 203 | } 204 | acc0 -= 100 205 | acc1 += 100 206 | release() 207 | for i := 0; i < len(data); i += 4 { 208 | data[i]++ 209 | } 210 | } 211 | }) 212 | } 213 | -------------------------------------------------------------------------------- /debounce-fswatcher/debounce-fswatcher.go: -------------------------------------------------------------------------------- 1 | package debounce_fswatcher 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/fsnotify/fsnotify" 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | // DebounceFSWatcherEvents debounces a fswatcher event stream. 12 | // Waits for a "quiet period" before syncing. 13 | // Returns when an event happened. 14 | // If filter is set, events are checked against the filter. 15 | // If filter returns false, the event will be ignored. 16 | // If filter returns an err, it will be returned. 17 | func DebounceFSWatcherEvents( 18 | ctx context.Context, 19 | watcher *fsnotify.Watcher, 20 | debounceDur time.Duration, 21 | filter func(event fsnotify.Event) (bool, error), 22 | ) ([]fsnotify.Event, error) { 23 | var happened []fsnotify.Event 24 | var nextSyncTicker *time.Timer 25 | var nextSyncC <-chan time.Time 26 | defer func() { 27 | if nextSyncTicker != nil { 28 | nextSyncTicker.Stop() 29 | } 30 | }() 31 | // flush first 32 | FlushLoop: 33 | for { 34 | select { 35 | case <-ctx.Done(): 36 | return nil, ctx.Err() 37 | case _, ok := <-watcher.Events: 38 | if !ok { 39 | return nil, nil 40 | } 41 | default: 42 | break FlushLoop 43 | } 44 | } 45 | for { 46 | select { 47 | case <-ctx.Done(): 48 | return nil, ctx.Err() 49 | case event, ok := <-watcher.Events: 50 | if !ok { 51 | return happened, nil 52 | } 53 | switch event.Op { 54 | case fsnotify.Create: 55 | case fsnotify.Rename: 56 | case fsnotify.Write: 57 | case fsnotify.Remove: 58 | default: 59 | continue 60 | } 61 | if filter != nil { 62 | keep, err := filter(event) 63 | if err != nil { 64 | return happened, err 65 | } 66 | if !keep { 67 | continue 68 | } 69 | } 70 | happened = append(happened, event) 71 | if nextSyncTicker != nil { 72 | nextSyncTicker.Stop() 73 | } 74 | nextSyncTicker = time.NewTimer(debounceDur) 75 | nextSyncC = nextSyncTicker.C 76 | case err, ok := <-watcher.Errors: 77 | if !ok || err == context.Canceled { 78 | return happened, nil 79 | } 80 | return nil, errors.Wrap(err, "watcher error") 81 | case <-nextSyncC: 82 | nextSyncTicker = nil 83 | return happened, nil 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /debounce-fswatcher/debounce-fswatcher_js.go: -------------------------------------------------------------------------------- 1 | package debounce_fswatcher 2 | -------------------------------------------------------------------------------- /deps.go: -------------------------------------------------------------------------------- 1 | //go:build deps_only 2 | // +build deps_only 3 | 4 | package util 5 | 6 | import ( 7 | // _ imports common with the Makefile and tools 8 | _ "github.com/aperturerobotics/common" 9 | ) 10 | -------------------------------------------------------------------------------- /doc/WEB_TESTS.md: -------------------------------------------------------------------------------- 1 | # Web Tests 2 | 3 | Some tests in this project require internet access to perform HTTP requests to external services. These tests are controlled by the `webtests` build tag. 4 | 5 | ## Running Web Tests 6 | 7 | To run tests that include web requests, use the following command: 8 | 9 | ``` 10 | go test -tags webtests ./... 11 | ``` 12 | 13 | This will enable the `webtests` build tag and run all tests, including those that make HTTP requests to external services. 14 | 15 | ### WebAssembly 16 | 17 | This package can be tested in a browser environment using [`wasmbrowsertest`](https://github.com/agnivade/wasmbrowsertest). 18 | 19 | 1. Install `wasmbrowsertest`: 20 | ```bash 21 | go install github.com/agnivade/wasmbrowsertest@latest 22 | ``` 23 | 24 | 2. Rename the `wasmbrowsertest` binary to `go_js_wasm_exec`: 25 | ```bash 26 | mv $(go env GOPATH)/bin/wasmbrowsertest $(go env GOPATH)/bin/go_js_wasm_exec 27 | ``` 28 | 29 | 3. Run the tests with the `js` GOOS and `wasm` GOARCH: 30 | ```bash 31 | GOOS=js GOARCH=wasm go test -tags "webtests" -v ./... 32 | ``` 33 | 34 | This will compile the tests to WebAssembly and run them in a headless browser environment. 35 | 36 | 37 | ## Skipping Web Tests 38 | 39 | By default, tests that require internet access are not run. To run all other tests without the web tests, simply run: 40 | 41 | ``` 42 | go test ./... 43 | ``` 44 | 45 | This will skip any tests that have the `webtests` build tag. 46 | 47 | ## Writing Web Tests 48 | 49 | When writing tests that require internet access or make HTTP requests to external services, make sure to add the `webtests` build tag to the test file. For example: 50 | 51 | ```go 52 | //go:build js && webtests 53 | 54 | package mypackage 55 | 56 | // Test code here 57 | ``` 58 | 59 | This ensures that these tests are only run when explicitly requested with the `webtests` build tag. 60 | -------------------------------------------------------------------------------- /enabled/enabled.go: -------------------------------------------------------------------------------- 1 | package enabled 2 | 3 | import "errors" 4 | 5 | // IsEnabled returns whether the option is enabled, given the default value. 6 | func (x Enabled) IsEnabled(defaultValue bool) bool { 7 | switch x { 8 | case Enabled_DEFAULT: 9 | return defaultValue 10 | case Enabled_ENABLE: 11 | return true 12 | case Enabled_DISABLE: 13 | return false 14 | default: 15 | return defaultValue 16 | } 17 | } 18 | 19 | // Validate returns an error if the Enabled value is invalid. 20 | func (x Enabled) Validate() error { 21 | switch x { 22 | case Enabled_DEFAULT, Enabled_ENABLE, Enabled_DISABLE: 23 | return nil 24 | default: 25 | return errors.New("invalid enabled value: " + x.String()) 26 | } 27 | } 28 | 29 | // Merge merges y into x overriding x if y is not set to DEFAULT. 30 | func (x Enabled) Merge(y Enabled) Enabled { 31 | if y == Enabled_DEFAULT { 32 | return x 33 | } 34 | return y 35 | } 36 | -------------------------------------------------------------------------------- /enabled/enabled.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go-lite. DO NOT EDIT. 2 | // protoc-gen-go-lite version: v0.9.1 3 | // source: github.com/aperturerobotics/util/enabled/enabled.proto 4 | 5 | package enabled 6 | 7 | import ( 8 | strconv "strconv" 9 | 10 | json "github.com/aperturerobotics/protobuf-go-lite/json" 11 | ) 12 | 13 | // Enabled is a three-way boolean: default, enable, disable. 14 | type Enabled int32 15 | 16 | const ( 17 | // DEFAULT defaults to the default option. 18 | Enabled_DEFAULT Enabled = 0 19 | // ENABLE enables the option even if it would normally be false. 20 | Enabled_ENABLE Enabled = 1 21 | // DISABLE disables the option even if it would normally be true. 22 | Enabled_DISABLE Enabled = 2 23 | ) 24 | 25 | // Enum value maps for Enabled. 26 | var ( 27 | Enabled_name = map[int32]string{ 28 | 0: "DEFAULT", 29 | 1: "ENABLE", 30 | 2: "DISABLE", 31 | } 32 | Enabled_value = map[string]int32{ 33 | "DEFAULT": 0, 34 | "ENABLE": 1, 35 | "DISABLE": 2, 36 | } 37 | ) 38 | 39 | func (x Enabled) Enum() *Enabled { 40 | p := new(Enabled) 41 | *p = x 42 | return p 43 | } 44 | 45 | func (x Enabled) String() string { 46 | name, valid := Enabled_name[int32(x)] 47 | if valid { 48 | return name 49 | } 50 | return strconv.Itoa(int(x)) 51 | } 52 | 53 | // MarshalProtoJSON marshals the Enabled to JSON. 54 | func (x Enabled) MarshalProtoJSON(s *json.MarshalState) { 55 | s.WriteEnumString(int32(x), Enabled_name) 56 | } 57 | 58 | // MarshalText marshals the Enabled to text. 59 | func (x Enabled) MarshalText() ([]byte, error) { 60 | return []byte(json.GetEnumString(int32(x), Enabled_name)), nil 61 | } 62 | 63 | // MarshalJSON marshals the Enabled to JSON. 64 | func (x Enabled) MarshalJSON() ([]byte, error) { 65 | return json.DefaultMarshalerConfig.Marshal(x) 66 | } 67 | 68 | // UnmarshalProtoJSON unmarshals the Enabled from JSON. 69 | func (x *Enabled) UnmarshalProtoJSON(s *json.UnmarshalState) { 70 | v := s.ReadEnum(Enabled_value) 71 | if err := s.Err(); err != nil { 72 | s.SetErrorf("could not read Enabled enum: %v", err) 73 | return 74 | } 75 | *x = Enabled(v) 76 | } 77 | 78 | // UnmarshalText unmarshals the Enabled from text. 79 | func (x *Enabled) UnmarshalText(b []byte) error { 80 | i, err := json.ParseEnumString(string(b), Enabled_value) 81 | if err != nil { 82 | return err 83 | } 84 | *x = Enabled(i) 85 | return nil 86 | } 87 | 88 | // UnmarshalJSON unmarshals the Enabled from JSON. 89 | func (x *Enabled) UnmarshalJSON(b []byte) error { 90 | return json.DefaultUnmarshalerConfig.Unmarshal(b, x) 91 | } 92 | 93 | func (x Enabled) MarshalProtoText() string { 94 | return x.String() 95 | } 96 | -------------------------------------------------------------------------------- /enabled/enabled.pb.ts: -------------------------------------------------------------------------------- 1 | // @generated by protoc-gen-es-lite unknown with parameter "target=ts,ts_nocheck=false" 2 | // @generated from file github.com/aperturerobotics/util/enabled/enabled.proto (package enabled, syntax proto3) 3 | /* eslint-disable */ 4 | 5 | import { createEnumType } from '@aptre/protobuf-es-lite' 6 | 7 | export const protobufPackage = 'enabled' 8 | 9 | /** 10 | * Enabled is a three-way boolean: default, enable, disable. 11 | * 12 | * @generated from enum enabled.Enabled 13 | */ 14 | export enum Enabled { 15 | /** 16 | * DEFAULT defaults to the default option. 17 | * 18 | * @generated from enum value: DEFAULT = 0; 19 | */ 20 | DEFAULT = 0, 21 | 22 | /** 23 | * ENABLE enables the option even if it would normally be false. 24 | * 25 | * @generated from enum value: ENABLE = 1; 26 | */ 27 | ENABLE = 1, 28 | 29 | /** 30 | * DISABLE disables the option even if it would normally be true. 31 | * 32 | * @generated from enum value: DISABLE = 2; 33 | */ 34 | DISABLE = 2, 35 | } 36 | 37 | // Enabled_Enum is the enum type for Enabled. 38 | export const Enabled_Enum = createEnumType('enabled.Enabled', [ 39 | { no: 0, name: 'DEFAULT' }, 40 | { no: 1, name: 'ENABLE' }, 41 | { no: 2, name: 'DISABLE' }, 42 | ]) 43 | -------------------------------------------------------------------------------- /enabled/enabled.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package enabled; 3 | 4 | // Enabled is a three-way boolean: default, enable, disable. 5 | enum Enabled { 6 | // DEFAULT defaults to the default option. 7 | DEFAULT = 0; 8 | // ENABLE enables the option even if it would normally be false. 9 | ENABLE = 1; 10 | // DISABLE disables the option even if it would normally be true. 11 | DISABLE = 2; 12 | } 13 | -------------------------------------------------------------------------------- /exec/exec.go: -------------------------------------------------------------------------------- 1 | package exec 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "io" 8 | "os" 9 | "os/exec" 10 | "strings" 11 | 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | // InterpretCmdErr interprets command errors and extracts meaningful error messages 16 | func InterpretCmdErr(err error, stderrBuf bytes.Buffer) error { 17 | if err != nil && (strings.HasPrefix(err.Error(), "exit status") || strings.HasPrefix(err.Error(), "err: exit status")) { 18 | stderrLines := strings.Split(stderrBuf.String(), "\n") 19 | errMsg := stderrLines[len(stderrLines)-1] 20 | if len(errMsg) == 0 && len(stderrLines) > 1 { 21 | errMsg = stderrLines[len(stderrLines)-2] 22 | } 23 | return errors.New(errMsg) 24 | } 25 | return err 26 | } 27 | 28 | // SetCmdLogger configures logging for the command 29 | func SetCmdLogger(le *logrus.Entry, cmd *exec.Cmd, buf *bytes.Buffer) { 30 | goLogger := le.WriterLevel(logrus.DebugLevel) 31 | cmd.Stderr = io.MultiWriter(buf, goLogger) 32 | } 33 | 34 | // NewCmd builds a new exec cmd with defaults. 35 | func NewCmd(ctx context.Context, proc string, args ...string) *exec.Cmd { 36 | ecmd := exec.CommandContext(ctx, proc, args...) 37 | ecmd.Env = make([]string, len(os.Environ())) 38 | copy(ecmd.Env, os.Environ()) 39 | ecmd.Stderr = os.Stderr 40 | ecmd.Stdout = os.Stdout 41 | return ecmd 42 | } 43 | 44 | // StartAndWait runs the given process and waits for ctx or process to complete. 45 | func StartAndWait(ctx context.Context, le *logrus.Entry, ecmd *exec.Cmd) error { 46 | if ecmd.Process == nil { 47 | var stderrBuf bytes.Buffer 48 | SetCmdLogger(le, ecmd, &stderrBuf) 49 | le.WithField("work-dir", ecmd.Dir). 50 | Debugf("running command: %s", ecmd.String()) 51 | if err := ecmd.Start(); err != nil { 52 | return err 53 | } 54 | } 55 | 56 | outErr := make(chan error, 1) 57 | go func() { 58 | outErr <- ecmd.Wait() 59 | }() 60 | 61 | select { 62 | case <-ctx.Done(): 63 | _ = ecmd.Process.Kill() 64 | <-outErr 65 | return ctx.Err() 66 | case err := <-outErr: 67 | le := le.WithField("exit-code", ecmd.ProcessState.ExitCode()) 68 | if err != nil { 69 | le.WithError(err).Debug("process exited with error") 70 | } else { 71 | le.Debug("process exited") 72 | } 73 | return err 74 | } 75 | } 76 | 77 | // ExecCmd runs the command and collects the log output. 78 | func ExecCmd(le *logrus.Entry, cmd *exec.Cmd) error { 79 | var stderrBuf bytes.Buffer 80 | SetCmdLogger(le, cmd, &stderrBuf) 81 | le. 82 | WithField("work-dir", cmd.Dir). 83 | Debugf("running command: %s", cmd.String()) 84 | 85 | err := cmd.Run() 86 | err = InterpretCmdErr(err, stderrBuf) 87 | 88 | return err 89 | } 90 | 91 | // StartCmd starts the command without waiting for it to complete and collects the log output. 92 | func StartCmd(le *logrus.Entry, cmd *exec.Cmd) error { 93 | var stderrBuf bytes.Buffer 94 | SetCmdLogger(le, cmd, &stderrBuf) 95 | le. 96 | WithField("work-dir", cmd.Dir). 97 | Debugf("running command: %s", cmd.String()) 98 | 99 | err := cmd.Start() 100 | return err 101 | } 102 | -------------------------------------------------------------------------------- /filter/filter.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "regexp" 5 | "slices" 6 | "strings" 7 | 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | // Validate validates the string filter. 12 | func (f *StringFilter) Validate() error { 13 | if reSrc := f.GetRe(); reSrc != "" { 14 | if _, err := regexp.Compile(reSrc); err != nil { 15 | return errors.Wrap(err, "re") 16 | } 17 | } 18 | return nil 19 | } 20 | 21 | // CheckMatch checks if the given value matches the string filter. 22 | func (f *StringFilter) CheckMatch(value string) bool { 23 | if f == nil { 24 | return true 25 | } 26 | if f.GetEmpty() && value != "" { 27 | return false 28 | } 29 | if f.GetNotEmpty() && value == "" { 30 | return false 31 | } 32 | if val := f.GetValue(); val != "" && value != val { 33 | return false 34 | } 35 | if matchValues := f.GetValues(); len(matchValues) != 0 && !slices.Contains(matchValues, value) { 36 | return false 37 | } 38 | if reSrc := f.GetRe(); reSrc != "" { 39 | rgx, err := regexp.Compile(reSrc) 40 | if err != nil { 41 | // checked in Validate but treat it as a fail 42 | return false 43 | } 44 | if !rgx.MatchString(value) { 45 | return false 46 | } 47 | } 48 | if prefixSrc := f.GetHasPrefix(); prefixSrc != "" { 49 | if !strings.HasPrefix(value, prefixSrc) { 50 | return false 51 | } 52 | } 53 | if suffixSrc := f.GetHasSuffix(); suffixSrc != "" { 54 | if !strings.HasSuffix(value, suffixSrc) { 55 | return false 56 | } 57 | } 58 | if containsSrc := f.GetContains(); containsSrc != "" { 59 | if !strings.Contains(value, containsSrc) { 60 | return false 61 | } 62 | } 63 | 64 | return true 65 | } 66 | -------------------------------------------------------------------------------- /filter/filter.pb.ts: -------------------------------------------------------------------------------- 1 | // @generated by protoc-gen-es-lite unknown with parameter "target=ts,ts_nocheck=false" 2 | // @generated from file github.com/aperturerobotics/util/filter/filter.proto (package filter, syntax proto3) 3 | /* eslint-disable */ 4 | 5 | import type { MessageType, PartialFieldInfo } from '@aptre/protobuf-es-lite' 6 | import { createMessageType, ScalarType } from '@aptre/protobuf-es-lite' 7 | 8 | export const protobufPackage = 'filter' 9 | 10 | /** 11 | * StringFilter matches the value of a string against a set of rules. 12 | * All of the non-zero rules must match for the filter to match. 13 | * An empty filter matches any. 14 | * 15 | * @generated from message filter.StringFilter 16 | */ 17 | export interface StringFilter { 18 | /** 19 | * Empty matches the value against the empty value. 20 | * 21 | * @generated from field: bool empty = 1; 22 | */ 23 | empty?: boolean 24 | /** 25 | * NotEmpty matches the value against a not-empty value. 26 | * 27 | * @generated from field: bool not_empty = 2; 28 | */ 29 | notEmpty?: boolean 30 | /** 31 | * Value matches an exact value. 32 | * 33 | * @generated from field: string value = 3; 34 | */ 35 | value?: string 36 | /** 37 | * Values matches one or more exact values. 38 | * If any of the values match, this field is considered matched. 39 | * 40 | * @generated from field: repeated string values = 4; 41 | */ 42 | values?: string[] 43 | /** 44 | * Re matches the value against a regular expression. 45 | * 46 | * @generated from field: string re = 5; 47 | */ 48 | re?: string 49 | /** 50 | * HasPrefix checks if the value has the given prefix. 51 | * 52 | * @generated from field: string has_prefix = 6; 53 | */ 54 | hasPrefix?: string 55 | /** 56 | * HasSuffix checks if the value has the given suffix. 57 | * 58 | * @generated from field: string has_suffix = 7; 59 | */ 60 | hasSuffix?: string 61 | /** 62 | * Contains checks if the value contains the given value. 63 | * 64 | * @generated from field: string contains = 8; 65 | */ 66 | contains?: string 67 | } 68 | 69 | // StringFilter contains the message type declaration for StringFilter. 70 | export const StringFilter: MessageType = createMessageType({ 71 | typeName: 'filter.StringFilter', 72 | fields: [ 73 | { no: 1, name: 'empty', kind: 'scalar', T: ScalarType.BOOL }, 74 | { no: 2, name: 'not_empty', kind: 'scalar', T: ScalarType.BOOL }, 75 | { no: 3, name: 'value', kind: 'scalar', T: ScalarType.STRING }, 76 | { 77 | no: 4, 78 | name: 'values', 79 | kind: 'scalar', 80 | T: ScalarType.STRING, 81 | repeated: true, 82 | }, 83 | { no: 5, name: 're', kind: 'scalar', T: ScalarType.STRING }, 84 | { no: 6, name: 'has_prefix', kind: 'scalar', T: ScalarType.STRING }, 85 | { no: 7, name: 'has_suffix', kind: 'scalar', T: ScalarType.STRING }, 86 | { no: 8, name: 'contains', kind: 'scalar', T: ScalarType.STRING }, 87 | ] as readonly PartialFieldInfo[], 88 | packedByDefault: true, 89 | }) 90 | -------------------------------------------------------------------------------- /filter/filter.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package filter; 3 | 4 | // StringFilter matches the value of a string against a set of rules. 5 | // All of the non-zero rules must match for the filter to match. 6 | // An empty filter matches any. 7 | message StringFilter { 8 | // Empty matches the value against the empty value. 9 | bool empty = 1; 10 | // NotEmpty matches the value against a not-empty value. 11 | bool not_empty = 2; 12 | // Value matches an exact value. 13 | string value = 3; 14 | // Values matches one or more exact values. 15 | // If any of the values match, this field is considered matched. 16 | repeated string values = 4; 17 | // Re matches the value against a regular expression. 18 | string re = 5; 19 | // HasPrefix checks if the value has the given prefix. 20 | string has_prefix = 6; 21 | // HasSuffix checks if the value has the given suffix. 22 | string has_suffix = 7; 23 | // Contains checks if the value contains the given value. 24 | string contains = 8; 25 | } 26 | -------------------------------------------------------------------------------- /fsutil/copy-file.go: -------------------------------------------------------------------------------- 1 | package fsutil 2 | 3 | import ( 4 | "io" 5 | "io/fs" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | // CopyFile copies the contents from src to dst. 11 | func CopyFile(dst, src string, perm os.FileMode) error { 12 | in, err := os.Open(src) 13 | if err != nil { 14 | return err 15 | } 16 | defer in.Close() 17 | out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, perm) 18 | if err != nil { 19 | return err 20 | } 21 | defer out.Close() 22 | _, err = io.Copy(out, in) 23 | if err != nil { 24 | _ = out.Close() 25 | _ = os.Remove(dst) 26 | } 27 | return err 28 | } 29 | 30 | // MoveFile moves the contents from src to dst. 31 | func MoveFile(dst, src string, perm os.FileMode) error { 32 | if err := CopyFile(dst, src, perm); err != nil { 33 | return err 34 | } 35 | if err := os.Remove(src); err != nil { 36 | return err 37 | } 38 | return nil 39 | } 40 | 41 | // CopyFileToDir copies the file to the dir maintaining the filename. 42 | func CopyFileToDir(dstDir, src string, perm os.FileMode) error { 43 | _, srcFilename := filepath.Split(src) 44 | return CopyFile(filepath.Join(dstDir, srcFilename), src, perm) 45 | } 46 | 47 | // MoveFileToDir moves the contents from src to dstDir maintaining the filename. 48 | func MoveFileToDir(dstDir, src string, perm os.FileMode) error { 49 | _, srcFilename := filepath.Split(src) 50 | return MoveFile(filepath.Join(dstDir, srcFilename), src, perm) 51 | } 52 | 53 | // CopyRecursive copies regular files & directories from src to dest. 54 | // 55 | // Calls the callback with the absolute path to the source file. 56 | // Ignore not-exist src dir by doing nothing. 57 | func CopyRecursive(dstDir, src string, cb fs.WalkDirFunc) error { 58 | if _, err := os.Stat(src); err != nil { 59 | if os.IsNotExist(err) { 60 | return nil 61 | } 62 | return err 63 | } 64 | return filepath.WalkDir(src, func(srcPath string, info fs.DirEntry, err error) error { 65 | if err != nil { 66 | return err 67 | } 68 | 69 | fi, err := info.Info() 70 | if err != nil { 71 | return err 72 | } 73 | 74 | srcRel, err := filepath.Rel(src, srcPath) 75 | if err != nil { 76 | return err 77 | } 78 | dstPath := filepath.Join(dstDir, srcRel) 79 | dstParent := filepath.Dir(dstPath) 80 | if err := os.MkdirAll(dstParent, 0o755); err != nil { 81 | return err 82 | } 83 | if info.Type().IsRegular() { 84 | if err := CopyFile(dstPath, srcPath, fi.Mode().Perm()); err != nil { 85 | return &fs.PathError{ 86 | Op: "copy", 87 | Path: srcRel, 88 | Err: err, 89 | } 90 | } 91 | } else if info.IsDir() { 92 | if err := os.MkdirAll(dstPath, 0o755); err != nil { 93 | return err 94 | } 95 | } else if info.Type()&fs.ModeSymlink != 0 { 96 | dstLink, err := os.Readlink(srcPath) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | if err := os.Symlink(dstLink, dstPath); err != nil { 102 | return err 103 | } 104 | } 105 | 106 | if cb != nil { 107 | if err := cb(srcPath, info, err); err != nil { 108 | return err 109 | } 110 | } 111 | 112 | return nil 113 | }) 114 | } 115 | -------------------------------------------------------------------------------- /fsutil/fsutil.go: -------------------------------------------------------------------------------- 1 | package fsutil 2 | 3 | import ( 4 | "io" 5 | "io/fs" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | // DefaultDirPerms is the default permission used when creating directories. 14 | const DefaultDirPerms fs.FileMode = 0o755 15 | 16 | // CleanDir deletes the given dir. 17 | func CleanDir(path string) error { 18 | if _, err := os.Stat(path); err != nil { 19 | if os.IsNotExist(err) { 20 | return nil 21 | } 22 | return err 23 | } 24 | if err := os.RemoveAll(path); err != nil { 25 | return err 26 | } 27 | return nil 28 | } 29 | 30 | // CleanCreateDir deletes the given dir and then re-creates it. 31 | func CleanCreateDir(path string) error { 32 | if err := CleanDir(path); err != nil { 33 | return err 34 | } 35 | if err := os.MkdirAll(path, DefaultDirPerms); err != nil { 36 | return err 37 | } 38 | return nil 39 | } 40 | 41 | // CreateDir creates the given dir if it doesn't exist. 42 | // Uses os.MkdirAll with default permissions defined by DefaultDirPerms. 43 | func CreateDir(path string) error { 44 | if err := os.MkdirAll(path, DefaultDirPerms); err != nil { 45 | return err 46 | } 47 | return nil 48 | } 49 | 50 | // CheckDirEmpty checks if the directory is empty. 51 | func CheckDirEmpty(path string) (bool, error) { 52 | var anyFiles bool 53 | err := filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error { 54 | if path == "." || path == "" { 55 | return nil 56 | } 57 | if err != nil { 58 | return err 59 | } 60 | anyFiles = true 61 | return io.EOF 62 | }) 63 | if anyFiles { 64 | return false, nil 65 | } 66 | if err == io.EOF { 67 | return false, nil 68 | } 69 | return false, err 70 | } 71 | 72 | // ConvertPathsToRelative converts a list of paths to relative. 73 | // Enforces that none of the paths are below the base dir. 74 | // Deduplicates the list of paths. 75 | func ConvertPathsToRelative(baseDir string, paths []string) error { 76 | var err error 77 | for i := range paths { 78 | if filepath.IsAbs(paths[i]) { 79 | paths[i], err = filepath.Rel(baseDir, paths[i]) 80 | if err != nil { 81 | return err 82 | } 83 | } 84 | paths[i] = filepath.Clean(paths[i]) 85 | if strings.HasPrefix(paths[i], "..") { 86 | return errors.Errorf("path cannot be above the base dir: %s", paths[i]) 87 | } 88 | } 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /gitcmd/gitcmd.go: -------------------------------------------------------------------------------- 1 | package gitcmd 2 | 3 | import ( 4 | "bufio" 5 | "os/exec" 6 | 7 | util_bufio "github.com/aperturerobotics/util/bufio" 8 | ) 9 | 10 | // ListGitFiles runs "git ls-files" to list all files in a Git workdir including 11 | // modified and untracked files, but not including deleted files. 12 | // 13 | // Returns the paths in ascending sorted order, with format "dir/file.txt". 14 | func ListGitFiles(workDir string) ([]string, error) { 15 | var files []string 16 | 17 | // Exec git ls-files with null terminated entries. 18 | cmd := exec.Command( 19 | "git", 20 | "ls-files", 21 | "-z", 22 | "--exclude-standard", 23 | "--others", 24 | "--cached", 25 | ) 26 | cmd.Dir = workDir 27 | 28 | stdout, err := cmd.StdoutPipe() 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | err = cmd.Start() 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | scanner := bufio.NewScanner(stdout) 39 | scanner.Split(util_bufio.SplitOnNul) 40 | 41 | for scanner.Scan() { 42 | file := scanner.Text() 43 | files = append(files, file) 44 | } 45 | 46 | err = scanner.Err() 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | err = cmd.Wait() 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | return files, nil 57 | } 58 | -------------------------------------------------------------------------------- /gitroot/gitroot.go: -------------------------------------------------------------------------------- 1 | package gitroot 2 | 3 | import ( 4 | "os/exec" 5 | "strings" 6 | ) 7 | 8 | // FindRepoRoot uses the Git Cli to find the git root dir. 9 | func FindRepoRoot() (string, error) { 10 | path, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() 11 | if err != nil { 12 | return "", err 13 | } 14 | return strings.TrimSpace(string(path)), nil 15 | } 16 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/aperturerobotics/util 2 | 3 | go 1.24 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/aperturerobotics/common v0.22.4 // latest 9 | github.com/aperturerobotics/json-iterator-lite v1.0.1-0.20240713111131-be6bf89c3008 // indirect 10 | github.com/aperturerobotics/protobuf-go-lite v0.9.1 // latest 11 | ) 12 | 13 | require ( 14 | github.com/fsnotify/fsnotify v1.9.0 15 | github.com/pkg/errors v0.9.1 16 | github.com/sirupsen/logrus v1.9.3 17 | ) 18 | 19 | require golang.org/x/sys v0.32.0 // indirect 20 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aperturerobotics/common v0.22.4 h1:y0Xxsz0oau3/IEbFev8SPfiaA9ZKhMlizQmkn7936jM= 2 | github.com/aperturerobotics/common v0.22.4/go.mod h1:wsPfDVCTNpGHddg/MSfm84rKoO4GAvb+TQtATXz+pKY= 3 | github.com/aperturerobotics/json-iterator-lite v1.0.1-0.20240713111131-be6bf89c3008 h1:So9JeziaWKx2Fw8sK4AUN/szqKtJ0jEMhS6bU4sHbxs= 4 | github.com/aperturerobotics/json-iterator-lite v1.0.1-0.20240713111131-be6bf89c3008/go.mod h1:snaApCEDtrHHP6UWSLKiYNOZU9A5NyzccKenx9oZEzg= 5 | github.com/aperturerobotics/protobuf-go-lite v0.9.1 h1:P1knXKnwLJpVE8fmeXYGckKu79IhqUvKRdCfJNR0MwQ= 6 | github.com/aperturerobotics/protobuf-go-lite v0.9.1/go.mod h1:fULrxQxEBWKQm7vvju9AfjTp9yfHoLgwMQWTiZQ2tg0= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 11 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 12 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 13 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 14 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 15 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 16 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 17 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 18 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 19 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 20 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 21 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 22 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 23 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 24 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 25 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 26 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 27 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 28 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 29 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 30 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 31 | -------------------------------------------------------------------------------- /gotargets/generate.go: -------------------------------------------------------------------------------- 1 | //go:build generate 2 | // +build generate 3 | 4 | package main 5 | 6 | import ( 7 | "bytes" 8 | "encoding/json" 9 | "fmt" 10 | "os" 11 | "os/exec" 12 | ) 13 | 14 | type GoDistEntry struct { 15 | GOOS string `json:"GOOS"` 16 | GOARCH string `json:"GOARCH"` 17 | } 18 | 19 | func main() { 20 | output, err := exec.Command("go", "tool", "dist", "list", "-json").Output() 21 | if err != nil { 22 | fmt.Fprintf(os.Stderr, "Error running 'go tool dist list -json': %v\n", err) 23 | os.Exit(1) 24 | } 25 | 26 | var entries []GoDistEntry 27 | if err := json.Unmarshal(output, &entries); err != nil { 28 | fmt.Fprintf(os.Stderr, "Error unmarshaling JSON output: %v\n", err) 29 | os.Exit(1) 30 | } 31 | 32 | var out bytes.Buffer 33 | out.WriteString("package gotargets\n\n") 34 | out.WriteString("type GoDistEntry struct {\n\tGOOS string `json:\"GOOS\"`\n\tGOARCH string `json:\"GOARCH\"`\n}\n\n") 35 | out.WriteString("var KnownGoDists = []*GoDistEntry{\n") 36 | for _, entry := range entries { 37 | fmt.Fprintf(&out, "\t{\n") 38 | fmt.Fprintf(&out, "\t\tGOOS: %q,\n", entry.GOOS) 39 | fmt.Fprintf(&out, "\t\tGOARCH: %q,\n", entry.GOARCH) 40 | fmt.Fprintf(&out, "\t},\n") 41 | } 42 | out.WriteString("}\n") 43 | 44 | err = os.WriteFile("gotargets.gen.go", out.Bytes(), 0o644) 45 | if err != nil { 46 | fmt.Fprintf(os.Stderr, "Error writing gotargets.gen.go: %v\n", err) 47 | os.Exit(1) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /gotargets/gotargets-generate.go: -------------------------------------------------------------------------------- 1 | //go:build generate 2 | // +build generate 3 | 4 | package main 5 | 6 | import ( 7 | "bytes" 8 | "encoding/json" 9 | "fmt" 10 | "os" 11 | "os/exec" 12 | ) 13 | 14 | type GoDistEntry struct { 15 | GOOS string `json:"GOOS"` 16 | GOARCH string `json:"GOARCH"` 17 | } 18 | 19 | func main() { 20 | output, err := exec.Command("go", "tool", "dist", "list", "-json").Output() 21 | if err != nil { 22 | fmt.Fprintf(os.Stderr, "Error running 'go tool dist list -json': %v\n", err) 23 | os.Exit(1) 24 | } 25 | 26 | var entries []GoDistEntry 27 | if err := json.Unmarshal(output, &entries); err != nil { 28 | fmt.Fprintf(os.Stderr, "Error unmarshaling JSON output: %v\n", err) 29 | os.Exit(1) 30 | } 31 | 32 | var out bytes.Buffer 33 | out.WriteString("package gotargets\n\n") 34 | out.WriteString("type GoDistEntry struct {\n\tGOOS string `json:\"GOOS\"`\n\tGOARCH string `json:\"GOARCH\"`\n}\n\n") 35 | out.WriteString("var KnownGoDists = []*GoDistEntry{\n") 36 | for _, entry := range entries { 37 | fmt.Fprintf(&out, "\t{\n") 38 | fmt.Fprintf(&out, "\t\tGOOS: %q,\n", entry.GOOS) 39 | fmt.Fprintf(&out, "\t\tGOARCH: %q,\n", entry.GOARCH) 40 | fmt.Fprintf(&out, "\t},\n") 41 | } 42 | out.WriteString("}\n") 43 | 44 | err = os.WriteFile("gotargets.gen.go", out.Bytes(), 0o644) 45 | if err != nil { 46 | fmt.Fprintf(os.Stderr, "Error writing gotargets.gen.go: %v\n", err) 47 | os.Exit(1) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /gotargets/gotargets.gen.go: -------------------------------------------------------------------------------- 1 | package gotargets 2 | 3 | type GoDistEntry struct { 4 | GOOS string `json:"GOOS"` 5 | GOARCH string `json:"GOARCH"` 6 | } 7 | 8 | var KnownGoDists = []*GoDistEntry{ 9 | { 10 | GOOS: "aix", 11 | GOARCH: "ppc64", 12 | }, 13 | { 14 | GOOS: "android", 15 | GOARCH: "386", 16 | }, 17 | { 18 | GOOS: "android", 19 | GOARCH: "amd64", 20 | }, 21 | { 22 | GOOS: "android", 23 | GOARCH: "arm", 24 | }, 25 | { 26 | GOOS: "android", 27 | GOARCH: "arm64", 28 | }, 29 | { 30 | GOOS: "darwin", 31 | GOARCH: "amd64", 32 | }, 33 | { 34 | GOOS: "darwin", 35 | GOARCH: "arm64", 36 | }, 37 | { 38 | GOOS: "dragonfly", 39 | GOARCH: "amd64", 40 | }, 41 | { 42 | GOOS: "freebsd", 43 | GOARCH: "386", 44 | }, 45 | { 46 | GOOS: "freebsd", 47 | GOARCH: "amd64", 48 | }, 49 | { 50 | GOOS: "freebsd", 51 | GOARCH: "arm", 52 | }, 53 | { 54 | GOOS: "freebsd", 55 | GOARCH: "arm64", 56 | }, 57 | { 58 | GOOS: "freebsd", 59 | GOARCH: "riscv64", 60 | }, 61 | { 62 | GOOS: "illumos", 63 | GOARCH: "amd64", 64 | }, 65 | { 66 | GOOS: "ios", 67 | GOARCH: "amd64", 68 | }, 69 | { 70 | GOOS: "ios", 71 | GOARCH: "arm64", 72 | }, 73 | { 74 | GOOS: "js", 75 | GOARCH: "wasm", 76 | }, 77 | { 78 | GOOS: "linux", 79 | GOARCH: "386", 80 | }, 81 | { 82 | GOOS: "linux", 83 | GOARCH: "amd64", 84 | }, 85 | { 86 | GOOS: "linux", 87 | GOARCH: "arm", 88 | }, 89 | { 90 | GOOS: "linux", 91 | GOARCH: "arm64", 92 | }, 93 | { 94 | GOOS: "linux", 95 | GOARCH: "loong64", 96 | }, 97 | { 98 | GOOS: "linux", 99 | GOARCH: "mips", 100 | }, 101 | { 102 | GOOS: "linux", 103 | GOARCH: "mips64", 104 | }, 105 | { 106 | GOOS: "linux", 107 | GOARCH: "mips64le", 108 | }, 109 | { 110 | GOOS: "linux", 111 | GOARCH: "mipsle", 112 | }, 113 | { 114 | GOOS: "linux", 115 | GOARCH: "ppc64", 116 | }, 117 | { 118 | GOOS: "linux", 119 | GOARCH: "ppc64le", 120 | }, 121 | { 122 | GOOS: "linux", 123 | GOARCH: "riscv64", 124 | }, 125 | { 126 | GOOS: "linux", 127 | GOARCH: "s390x", 128 | }, 129 | { 130 | GOOS: "netbsd", 131 | GOARCH: "386", 132 | }, 133 | { 134 | GOOS: "netbsd", 135 | GOARCH: "amd64", 136 | }, 137 | { 138 | GOOS: "netbsd", 139 | GOARCH: "arm", 140 | }, 141 | { 142 | GOOS: "netbsd", 143 | GOARCH: "arm64", 144 | }, 145 | { 146 | GOOS: "openbsd", 147 | GOARCH: "386", 148 | }, 149 | { 150 | GOOS: "openbsd", 151 | GOARCH: "amd64", 152 | }, 153 | { 154 | GOOS: "openbsd", 155 | GOARCH: "arm", 156 | }, 157 | { 158 | GOOS: "openbsd", 159 | GOARCH: "arm64", 160 | }, 161 | { 162 | GOOS: "openbsd", 163 | GOARCH: "mips64", 164 | }, 165 | { 166 | GOOS: "plan9", 167 | GOARCH: "386", 168 | }, 169 | { 170 | GOOS: "plan9", 171 | GOARCH: "amd64", 172 | }, 173 | { 174 | GOOS: "plan9", 175 | GOARCH: "arm", 176 | }, 177 | { 178 | GOOS: "solaris", 179 | GOARCH: "amd64", 180 | }, 181 | { 182 | GOOS: "windows", 183 | GOARCH: "386", 184 | }, 185 | { 186 | GOOS: "windows", 187 | GOARCH: "amd64", 188 | }, 189 | { 190 | GOOS: "windows", 191 | GOARCH: "arm", 192 | }, 193 | { 194 | GOOS: "windows", 195 | GOARCH: "arm64", 196 | }, 197 | } 198 | -------------------------------------------------------------------------------- /gotargets/gotargets.go: -------------------------------------------------------------------------------- 1 | package gotargets 2 | 3 | import "sort" 4 | 5 | //go:generate go run -v -tags=generate generate.go 6 | 7 | // GetOsArchValues returns the list of GOARCH values for each GOOS. 8 | func GetOsArchValues() map[string][]string { 9 | m := make(map[string][]string, len(KnownGoDists)) 10 | for _, kd := range KnownGoDists { 11 | m[kd.GOOS] = append(m[kd.GOOS], kd.GOARCH) 12 | } 13 | for k := range m { 14 | sort.Strings(m[k]) 15 | } 16 | return m 17 | } 18 | -------------------------------------------------------------------------------- /httplog/client.go: -------------------------------------------------------------------------------- 1 | package httplog 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // DoRequest performs a request with logging. 11 | // 12 | // If verbose=true, logs successful cases as well as errors. 13 | // le can be nil to disable logging 14 | func DoRequest(le *logrus.Entry, client *http.Client, req *http.Request, verbose bool) (*http.Response, error) { 15 | return DoRequestWithClient(le, client, req, verbose) 16 | } 17 | 18 | // roundTripperClient converts http.RoundTripper to HttpClient. 19 | type roundTripperClient struct { 20 | rt http.RoundTripper 21 | } 22 | 23 | // Do performs the request. 24 | func (r *roundTripperClient) Do(req *http.Request) (*http.Response, error) { 25 | return r.rt.RoundTrip(req) 26 | } 27 | 28 | // _ is a type assertion 29 | var _ HttpClient = (*roundTripperClient)(nil) 30 | 31 | // DoRequestWithTransport performs a request with logging. 32 | // 33 | // If verbose=true, logs successful cases as well as errors. 34 | // le can be nil to disable logging 35 | func DoRequestWithTransport(le *logrus.Entry, transport http.RoundTripper, req *http.Request, verbose bool) (*http.Response, error) { 36 | return DoRequestWithClient(le, &roundTripperClient{rt: transport}, req, verbose) 37 | } 38 | 39 | // loggedClient wraps http.Client to HttpClient with a logger. 40 | type loggedClient struct { 41 | client HttpClient 42 | le *logrus.Entry 43 | verbose bool 44 | } 45 | 46 | // Do performs the request. 47 | func (l *loggedClient) Do(req *http.Request) (*http.Response, error) { 48 | return DoRequestWithClient(l.le, l.client, req, l.verbose) 49 | } 50 | 51 | // _ is a type assertion 52 | var _ HttpClient = (*loggedClient)(nil) 53 | 54 | // NewLoggedClient wraps an HttpClient with a logger. 55 | func NewLoggedClient(le *logrus.Entry, client HttpClient, verbose bool) HttpClient { 56 | return &loggedClient{ 57 | client: client, 58 | le: le, 59 | verbose: verbose, 60 | } 61 | } 62 | 63 | // HttpClient can perform http requests. 64 | type HttpClient interface { 65 | Do(req *http.Request) (*http.Response, error) 66 | } 67 | 68 | // DoRequestWithClient performs a request with logging. 69 | // 70 | // If verbose=true, logs successful cases as well as errors. 71 | // le can be nil to disable logging 72 | func DoRequestWithClient(le *logrus.Entry, client HttpClient, req *http.Request, verbose bool) (*http.Response, error) { 73 | // Request details 74 | if le != nil { 75 | le = le.WithFields(logrus.Fields{ 76 | "method": req.Method, 77 | "url": req.URL.String(), 78 | }) 79 | 80 | // Parse and log the Range header from the request 81 | if rangeHeader := req.Header.Get("Range"); rangeHeader != "" { 82 | le = le.WithField("range", rangeHeader) 83 | } 84 | 85 | if verbose { 86 | le.Debug("starting request") 87 | } 88 | } 89 | 90 | var resp *http.Response 91 | var err error 92 | startTime := time.Now() 93 | if client != nil { 94 | resp, err = client.Do(req) 95 | } else { 96 | resp, err = http.DefaultClient.Do(req) 97 | } 98 | duration := time.Since(startTime) 99 | 100 | if le != nil { 101 | fields := make(logrus.Fields, 3) 102 | fields["dur"] = duration.String() 103 | if resp != nil { 104 | fields["status"] = resp.StatusCode 105 | 106 | // Parse and log the Content-Range header from the response 107 | if contentRangeHeader := resp.Header.Get("Content-Range"); contentRangeHeader != "" { 108 | fields["response-range"] = contentRangeHeader 109 | } 110 | } 111 | 112 | le := le.WithFields(fields) 113 | if err != nil { 114 | le.WithError(err).Warn("request errored") 115 | } else if resp == nil || resp.StatusCode >= 400 { 116 | le.Warn("request failed") 117 | } else if verbose { 118 | le.Debug("request succeeded") 119 | } 120 | } 121 | 122 | return resp, err 123 | } 124 | 125 | // LoggedRoundTripper is a custom RoundTripper that wraps an existing RoundTripper with a logger. 126 | type LoggedRoundTripper struct { 127 | transport http.RoundTripper 128 | le *logrus.Entry 129 | verbose bool 130 | } 131 | 132 | // NewLoggedRoundTripper creates a new instance of LoggedRoundTripper. 133 | func NewLoggedRoundTripper(transport http.RoundTripper, le *logrus.Entry, verbose bool) *LoggedRoundTripper { 134 | return &LoggedRoundTripper{ 135 | transport: transport, 136 | le: le, 137 | verbose: verbose, 138 | } 139 | } 140 | 141 | // RoundTrip implements the RoundTripper interface. 142 | func (t *LoggedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { 143 | return DoRequestWithTransport(t.le, t.transport, req, t.verbose) 144 | } 145 | -------------------------------------------------------------------------------- /httplog/client_test.go: -------------------------------------------------------------------------------- 1 | //go:build !js 2 | 3 | package httplog 4 | 5 | import ( 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | func TestDoRequest(t *testing.T) { 14 | logger := logrus.New() 15 | logger.SetLevel(logrus.DebugLevel) 16 | entry := logrus.NewEntry(logger) 17 | 18 | // Create a test server that responds with 200 OK 19 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) 20 | defer ts.Close() 21 | 22 | client := &http.Client{} 23 | req, _ := http.NewRequest("GET", ts.URL, nil) 24 | resp, err := DoRequest(entry, client, req, true) 25 | if err != nil { 26 | t.Errorf("Expected no error, got %v", err) 27 | } 28 | if resp.StatusCode != http.StatusOK { 29 | t.Errorf("Expected status 200, got %v", resp.StatusCode) 30 | } 31 | 32 | // Create a test server that responds with 500 Internal Server Error 33 | ts2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 34 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 35 | })) 36 | defer ts2.Close() 37 | 38 | req2, _ := http.NewRequest("GET", ts2.URL, nil) 39 | resp2, err2 := DoRequest(entry, client, req2, true) 40 | 41 | if err2 != nil { 42 | t.Errorf("Expected no error, got %v", err2) 43 | } 44 | if resp2.StatusCode != http.StatusInternalServerError { 45 | t.Errorf("Expected status 500, got %v", resp2.StatusCode) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /httplog/fetch/fetch.go: -------------------------------------------------------------------------------- 1 | package httplog_fetch 2 | -------------------------------------------------------------------------------- /httplog/fetch/fetch_js.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | 3 | package httplog_fetch 4 | 5 | import ( 6 | "net/textproto" 7 | "slices" 8 | "time" 9 | 10 | fetch "github.com/aperturerobotics/util/js/fetch" 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | // logHeaders is the set of headers to attach to the logger as fields. 15 | var logHeaders = []string{"range", "content-range", "content-type", "content-length", "accept"} 16 | 17 | // Fetch uses the JS Fetch API to make requests with logging. 18 | // 19 | // if le is nil, all logging is disabled. 20 | // if verbose is set, both successful and failed calls are logged. 21 | func Fetch(le *logrus.Entry, url string, opts *fetch.Opts, verbose bool) (*fetch.Response, error) { 22 | // Request details 23 | if le != nil { 24 | method := "GET" 25 | if opts != nil && opts.Method != "" { 26 | method = opts.Method 27 | } 28 | 29 | le = le.WithFields(logrus.Fields{ 30 | "method": method, 31 | "url": url, 32 | }) 33 | 34 | if opts != nil && opts.Header != nil { 35 | // Parse and log some headers from the request 36 | for hdr, hdrVal := range opts.Header { 37 | hdr = fetch.CanonicalHeaderKey(hdr) 38 | if slices.Contains(logHeaders, hdr) { 39 | le = le.WithField(hdr, hdrVal) 40 | } 41 | } 42 | } 43 | 44 | if verbose { 45 | le.Debug("starting request") 46 | } 47 | } 48 | 49 | startTime := time.Now() 50 | resp, err := fetch.Fetch(url, opts) 51 | duration := time.Since(startTime) 52 | 53 | if le != nil { 54 | mapSize := 1 55 | if resp != nil { 56 | mapSize += 1 + min(len(resp.Header), len(logHeaders)) 57 | } 58 | fields := make(logrus.Fields, mapSize) 59 | fields["dur"] = duration.String() 60 | if resp != nil { 61 | fields["status"] = resp.Status 62 | for hdr, hdrVal := range resp.Header { 63 | hdr = textproto.CanonicalMIMEHeaderKey(hdr) 64 | if slices.Contains(logHeaders, hdr) { 65 | fields[hdr] = hdrVal 66 | } 67 | } 68 | } 69 | 70 | if err != nil { 71 | le.WithError(err).Warn("request errored") 72 | } else if resp == nil || resp.StatusCode >= 400 { 73 | le.Warn("request failed") 74 | } else if verbose { 75 | le.Debug("request succeeded") 76 | } 77 | } 78 | 79 | return resp, err 80 | } 81 | -------------------------------------------------------------------------------- /httplog/fetch/fetch_test.go: -------------------------------------------------------------------------------- 1 | //go:build js && webtests 2 | 3 | package httplog_fetch 4 | 5 | import ( 6 | "bytes" 7 | "net/http" 8 | "testing" 9 | 10 | fetch "github.com/aperturerobotics/util/js/fetch" 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | func TestFetch(t *testing.T) { 15 | // Create a logger 16 | logger := logrus.New() 17 | logger.SetLevel(logrus.DebugLevel) 18 | 19 | // Test cases 20 | testCases := []struct { 21 | name string 22 | url string 23 | opts *fetch.Opts 24 | verbose bool 25 | expectError bool 26 | }{ 27 | { 28 | name: "Successful GET request", 29 | url: "https://httpbin.org/get", 30 | verbose: true, 31 | }, 32 | { 33 | name: "POST request with headers", 34 | url: "https://httpbin.org/post", 35 | opts: &fetch.Opts{ 36 | Method: "POST", 37 | Header: map[string][]string{ 38 | "Content-Type": {"application/json"}, 39 | }, 40 | Body: bytes.NewReader([]byte(`{"test": "data"}`)), 41 | }, 42 | verbose: true, 43 | }, 44 | { 45 | name: "Non-existent URL", 46 | url: "https://thisurldoesnotexist.example.com", 47 | expectError: true, 48 | }, 49 | } 50 | 51 | for _, tc := range testCases { 52 | t.Run(tc.name, func(t *testing.T) { 53 | le := logger.WithField("test", tc.name) 54 | 55 | resp, err := Fetch(le, tc.url, tc.opts, tc.verbose) 56 | 57 | if tc.expectError { 58 | if err == nil { 59 | t.Errorf("Expected an error, but got nil") 60 | } 61 | if resp != nil { 62 | t.Errorf("Expected nil response, but got %v", resp) 63 | } 64 | } else { 65 | if err != nil { 66 | t.Fatalf("Unexpected error: %v", err) 67 | } 68 | if resp == nil { 69 | t.Fatalf("Expected non-nil response, but got nil") 70 | } 71 | if resp.StatusCode != http.StatusOK { 72 | t.Errorf("Expected status %d, but got %v", http.StatusOK, resp.StatusCode) 73 | } 74 | } 75 | }) 76 | } 77 | } 78 | 79 | func TestFetchWithNilLogger(t *testing.T) { 80 | resp, err := Fetch(nil, "https://httpbin.org/get", nil, false) 81 | if err != nil { 82 | t.Fatalf("Unexpected error: %v", err) 83 | } 84 | if resp == nil { 85 | t.Fatal("Expected non-nil response, but got nil") 86 | } 87 | if resp.StatusCode != http.StatusOK { 88 | t.Errorf("Expected status %d, but got %d", http.StatusOK, resp.StatusCode) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /httplog/server.go: -------------------------------------------------------------------------------- 1 | package httplog 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/sirupsen/logrus" 7 | ) 8 | 9 | // LoggingMiddlewareOpts are opts passed to LoggingMiddleware. 10 | type LoggingMiddlewareOpts struct { 11 | // UserAgent includes user agent in logs. 12 | UserAgent bool 13 | } 14 | 15 | // LoggingMiddleware logs incoming requests and response status codes using logrus. 16 | func LoggingMiddleware(next http.Handler, le *logrus.Entry, opts LoggingMiddlewareOpts) http.Handler { 17 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 | // Wrap the response writer to capture the status code 19 | wrappedWriter := &statusCapturingResponseWriter{ResponseWriter: w} 20 | 21 | // Call the next handler 22 | next.ServeHTTP(wrappedWriter, r) 23 | 24 | // Log the request and response status code 25 | WithLoggerFields(le, r, wrappedWriter.statusCode). 26 | Debug("handled request") 27 | }) 28 | } 29 | 30 | // WithLoggerFields builds the log fields for a request. 31 | func WithLoggerFields(le *logrus.Entry, r *http.Request, status int) *logrus.Entry { 32 | fields := logrus.Fields{ 33 | "method": r.Method, 34 | "uri": r.RequestURI, 35 | } 36 | if userAgent := r.UserAgent(); userAgent != "" { 37 | fields["user-agent"] = userAgent 38 | } 39 | if status != 0 { 40 | fields["status"] = status 41 | } 42 | return le.WithFields(fields) 43 | } 44 | 45 | type statusCapturingResponseWriter struct { 46 | http.ResponseWriter 47 | statusCode int 48 | } 49 | 50 | func (w *statusCapturingResponseWriter) WriteHeader(statusCode int) { 51 | w.ResponseWriter.WriteHeader(statusCode) 52 | w.statusCode = statusCode 53 | } 54 | 55 | // Flush sends any buffered data to the client. 56 | func (w *statusCapturingResponseWriter) Flush() { 57 | flusher, ok := w.ResponseWriter.(http.Flusher) 58 | if ok { 59 | flusher.Flush() 60 | } 61 | } 62 | 63 | // _ is a type assertion 64 | var ( 65 | _ http.ResponseWriter = (*statusCapturingResponseWriter)(nil) 66 | _ http.Flusher = (*statusCapturingResponseWriter)(nil) 67 | ) 68 | -------------------------------------------------------------------------------- /httplog/server_test.go: -------------------------------------------------------------------------------- 1 | //go:build !js 2 | 3 | package httplog 4 | 5 | import ( 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "net/url" 10 | "testing" 11 | 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | // TestHTTPLogServer tests logging http requests to logrus. 16 | func TestHTTPLogServer(t *testing.T) { 17 | log := logrus.New() 18 | log.SetLevel(logrus.DebugLevel) 19 | le := logrus.NewEntry(log) 20 | 21 | handler := http.NewServeMux() 22 | handler.HandleFunc("/test", func(rw http.ResponseWriter, req *http.Request) { 23 | rw.WriteHeader(200) 24 | _, _ = rw.Write([]byte("hello world!\n")) 25 | }) 26 | 27 | srv := httptest.NewServer(LoggingMiddleware(handler, le, LoggingMiddlewareOpts{UserAgent: true})) 28 | defer srv.Close() 29 | baseURL, _ := url.Parse(srv.URL) 30 | baseURL = baseURL.JoinPath("test") 31 | 32 | // Create the client 33 | client := srv.Client() 34 | 35 | // Get the test endpoint 36 | resp, err := client.Get(baseURL.String()) 37 | if err != nil { 38 | t.Fatal(err.Error()) 39 | } 40 | 41 | data, err := io.ReadAll(resp.Body) 42 | if err != nil { 43 | t.Fatal(err.Error()) 44 | } 45 | if string(data) != "hello world!\n" || resp.StatusCode != 200 { 46 | t.Fail() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /iocloser/read-closer.go: -------------------------------------------------------------------------------- 1 | package iocloser 2 | 3 | import ( 4 | "io" 5 | "sync" 6 | ) 7 | 8 | // ReadCloser wraps a writer to make a ReadCloser. 9 | type ReadCloser struct { 10 | closeMtx sync.Mutex 11 | rd io.Reader 12 | close func() error 13 | } 14 | 15 | // NewReadCloser builds a new write closer 16 | func NewReadCloser(rd io.Reader, close func() error) *ReadCloser { 17 | return &ReadCloser{ 18 | rd: rd, 19 | close: close, 20 | } 21 | } 22 | 23 | // Read writes data to the io.Readr. 24 | func (w *ReadCloser) Read(p []byte) (n int, err error) { 25 | w.closeMtx.Lock() 26 | defer w.closeMtx.Unlock() 27 | if w.rd == nil { 28 | // Close() already called 29 | return 0, io.EOF 30 | } 31 | return w.rd.Read(p) 32 | } 33 | 34 | // Close closes the ReadCloser. 35 | func (w *ReadCloser) Close() error { 36 | w.closeMtx.Lock() 37 | closeFn := w.close 38 | w.rd = nil 39 | w.close = nil 40 | w.closeMtx.Unlock() 41 | if closeFn != nil { 42 | return closeFn() 43 | } 44 | return nil 45 | } 46 | 47 | // _ is a type assertion 48 | var _ io.ReadCloser = ((*ReadCloser)(nil)) 49 | -------------------------------------------------------------------------------- /iocloser/write-closer.go: -------------------------------------------------------------------------------- 1 | package iocloser 2 | 3 | import ( 4 | "io" 5 | "sync" 6 | ) 7 | 8 | // WriteCloser wraps a writer to make a WriteCloser. 9 | type WriteCloser struct { 10 | closeMtx sync.Mutex 11 | wr io.Writer 12 | close func() error 13 | } 14 | 15 | // NewWriteCloser builds a new write closer 16 | func NewWriteCloser(wr io.Writer, close func() error) *WriteCloser { 17 | return &WriteCloser{ 18 | wr: wr, 19 | close: close, 20 | } 21 | } 22 | 23 | // Write writes data to the io.Writer. 24 | func (w *WriteCloser) Write(p []byte) (n int, err error) { 25 | w.closeMtx.Lock() 26 | defer w.closeMtx.Unlock() 27 | if w.wr == nil { 28 | // Close() already called 29 | return 0, io.EOF 30 | } 31 | return w.wr.Write(p) 32 | } 33 | 34 | // Close closes the WriteCloser. 35 | func (w *WriteCloser) Close() error { 36 | w.closeMtx.Lock() 37 | closeFn := w.close 38 | w.wr = nil 39 | w.close = nil 40 | w.closeMtx.Unlock() 41 | if closeFn != nil { 42 | return closeFn() 43 | } 44 | return nil 45 | } 46 | 47 | // _ is a type assertion 48 | var _ io.WriteCloser = ((*WriteCloser)(nil)) 49 | -------------------------------------------------------------------------------- /ioproxy/ioproxy.go: -------------------------------------------------------------------------------- 1 | package ioproxy 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | // ProxyStreams constructs read/write pumps to proxy two streams. 8 | // if either stream is closed, the other will be closed. 9 | // The two routines will exit when the streams are closed. 10 | // Cb will be called if either of the streams close, and will be called twice. 11 | func ProxyStreams(s1, s2 io.ReadWriteCloser, cb func()) { 12 | go proxyTo(s1, s2, cb) 13 | go proxyTo(s2, s1, cb) 14 | } 15 | 16 | func proxyTo(s1, s2 io.ReadWriteCloser, cb func()) { 17 | buf := make([]byte, 8192) 18 | _, _ = io.CopyBuffer(s2, s1, buf) 19 | // io.Copy(s2, s1) 20 | s1.Close() 21 | s2.Close() 22 | if cb != nil { 23 | cb() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ioproxy/ioproxy_test.go: -------------------------------------------------------------------------------- 1 | package ioproxy 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "sync" 7 | "testing" 8 | ) 9 | 10 | func TestProxyStreams(t *testing.T) { 11 | data := []byte("Hello, World!") 12 | 13 | // Create pipe connections to simulate the streams. 14 | s1Reader, s1Writer := io.Pipe() 15 | s2Reader, s2Writer := io.Pipe() 16 | 17 | // Wrap the pipe connections with our custom readWriteCloser type. 18 | s1 := &readWriteCloser{ 19 | Reader: s1Reader, 20 | Writer: s1Writer, 21 | Closer: s1Writer, 22 | } 23 | s2 := &readWriteCloser{ 24 | Reader: s2Reader, 25 | Writer: s2Writer, 26 | Closer: s2Writer, 27 | } 28 | 29 | // Initialize the wait group to synchronize the callbacks. 30 | wg := sync.WaitGroup{} 31 | wg.Add(2) 32 | 33 | // Create a callback function to be called when the streams are closed. 34 | cb := func() { 35 | wg.Done() 36 | } 37 | 38 | // Start proxying the streams. 39 | ProxyStreams(s1, s2, cb) 40 | 41 | // Write the data to s1Writer. 42 | go func() { 43 | _, _ = s1Writer.Write(data) 44 | s1Writer.Close() 45 | }() 46 | 47 | // Read the data from s2Reader. 48 | buf, err := io.ReadAll(s2Reader) 49 | if err != nil { 50 | t.Fatal(err.Error()) 51 | } 52 | 53 | // Wait for the callbacks to be called. 54 | wg.Wait() 55 | 56 | // Check if the data was correctly proxied. 57 | if !bytes.Equal(data, buf) { 58 | t.Fail() 59 | } 60 | } 61 | 62 | type readWriteCloser struct { 63 | io.Reader 64 | io.Writer 65 | io.Closer 66 | } 67 | -------------------------------------------------------------------------------- /ioseek/reader-at-seeker.go: -------------------------------------------------------------------------------- 1 | package ioseek 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | ) 7 | 8 | // ReaderAtSeeker wraps an io.ReaderAt to provide io.Seeker behavior. 9 | // It embeds io.RederAt, thereby inheriting its methods directly. 10 | type ReaderAtSeeker struct { 11 | io.ReaderAt 12 | size int64 // The size of the file 13 | offset int64 // The current offset within the file 14 | } 15 | 16 | // NewReaderAtSeeker creates a new ReaderAtSeeker with the provided io.ReaderAt and file size. 17 | func NewReaderAtSeeker(readerAt io.ReaderAt, size int64) *ReaderAtSeeker { 18 | return &ReaderAtSeeker{ 19 | ReaderAt: readerAt, 20 | size: size, 21 | offset: 0, 22 | } 23 | } 24 | 25 | // Seek implements the io.Seeker interface. 26 | func (r *ReaderAtSeeker) Seek(offset int64, whence int) (int64, error) { 27 | var newOffset int64 28 | switch whence { 29 | case io.SeekStart: 30 | newOffset = offset 31 | case io.SeekCurrent: 32 | newOffset = r.offset + offset 33 | case io.SeekEnd: 34 | newOffset = r.size + offset 35 | default: 36 | return 0, errors.New("ReaderAtSeeker.Seek: invalid whence") 37 | } 38 | 39 | // Check for seeking before the start of the file 40 | if newOffset < 0 { 41 | return 0, errors.New("ReaderAtSeeker.Seek: negative position") 42 | } 43 | 44 | // If seeking beyond the end of the file, return 0 and io.EOF 45 | if newOffset > r.size { 46 | return 0, io.EOF 47 | } 48 | 49 | r.offset = newOffset 50 | return r.offset, nil 51 | } 52 | 53 | // Read reads up to len(p) bytes into p. 54 | func (r *ReaderAtSeeker) Read(p []byte) (n int, err error) { 55 | n, err = r.ReadAt(p, r.offset) 56 | r.offset += int64(n) 57 | return n, err 58 | } 59 | 60 | // _ is a type assertion 61 | var _ io.ReadSeeker = ((*ReaderAtSeeker)(nil)) 62 | -------------------------------------------------------------------------------- /iosizer/iosizer.go: -------------------------------------------------------------------------------- 1 | package iosizer 2 | 3 | import ( 4 | "io" 5 | "math" 6 | "sync/atomic" 7 | ) 8 | 9 | // SizeReadWriter implements io methods keeping total size metrics. 10 | type SizeReadWriter struct { 11 | total atomic.Uint64 12 | rdr io.Reader 13 | wtr io.Writer 14 | } 15 | 16 | // NewSizeReadWriter constructs a read/writer with size metrics. 17 | func NewSizeReadWriter(rdr io.Reader, writer io.Writer) *SizeReadWriter { 18 | return &SizeReadWriter{rdr: rdr, wtr: writer} 19 | } 20 | 21 | // TotalSize returns the total amount of data transferred. 22 | func (s *SizeReadWriter) TotalSize() uint64 { 23 | return s.total.Load() 24 | } 25 | 26 | // Read reads data from the source. 27 | func (s *SizeReadWriter) Read(p []byte) (n int, err error) { 28 | if s.rdr == nil { 29 | return 0, io.EOF 30 | } 31 | n, err = s.rdr.Read(p) 32 | // G115: Protect against integer overflow by checking n <= math.MaxUint32 before conversion to uint64 33 | // G115: Protect against integer overflow by checking n <= math.MaxUint32 before conversion to uint64 34 | if n > 0 && n <= math.MaxUint32 { 35 | s.total.Add(uint64(n)) 36 | } 37 | return 38 | } 39 | 40 | // Write writes data to the writer. 41 | func (s *SizeReadWriter) Write(p []byte) (n int, err error) { 42 | if s.wtr == nil { 43 | return 0, io.EOF 44 | } 45 | n, err = s.wtr.Write(p) 46 | if n > 0 && n <= math.MaxUint32 { 47 | s.total.Add(uint64(n)) 48 | } 49 | return 50 | } 51 | 52 | // _ is a type assertion 53 | var _ io.ReadWriter = ((*SizeReadWriter)(nil)) 54 | -------------------------------------------------------------------------------- /iosizer/iosizer_test.go: -------------------------------------------------------------------------------- 1 | package iosizer 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func TestSizeReadWriter(t *testing.T) { 11 | // Test data 12 | testData := "hello world" 13 | reader := strings.NewReader(testData) 14 | writer := &bytes.Buffer{} 15 | 16 | // Create SizeReadWriter 17 | srw := NewSizeReadWriter(reader, writer) 18 | 19 | // Test initial size 20 | if size := srw.TotalSize(); size != 0 { 21 | t.Fatalf("expected initial size 0, got %d", size) 22 | } 23 | 24 | // Test reading 25 | buf := make([]byte, 5) 26 | n, err := srw.Read(buf) 27 | if err != nil { 28 | t.Fatalf("unexpected read error: %v", err) 29 | } 30 | if n != 5 { 31 | t.Fatalf("expected to read 5 bytes, got %d", n) 32 | } 33 | if string(buf) != "hello" { 34 | t.Fatalf("expected 'hello', got '%s'", string(buf)) 35 | } 36 | if size := srw.TotalSize(); size != 5 { 37 | t.Fatalf("expected size 5 after read, got %d", size) 38 | } 39 | 40 | // Test writing 41 | writeData := []byte("test") 42 | n, err = srw.Write(writeData) 43 | if err != nil { 44 | t.Fatalf("unexpected write error: %v", err) 45 | } 46 | if n != 4 { 47 | t.Fatalf("expected to write 4 bytes, got %d", n) 48 | } 49 | if writer.String() != "test" { 50 | t.Fatalf("expected writer to contain 'test', got '%s'", writer.String()) 51 | } 52 | if size := srw.TotalSize(); size != 9 { 53 | t.Fatalf("expected total size 9 after read+write, got %d", size) 54 | } 55 | 56 | // Test nil reader/writer 57 | nilSrw := NewSizeReadWriter(nil, nil) 58 | 59 | _, err = nilSrw.Read(buf) 60 | if err != io.EOF { 61 | t.Fatalf("expected EOF for nil reader, got %v", err) 62 | } 63 | 64 | _, err = nilSrw.Write(writeData) 65 | if err != io.EOF { 66 | t.Fatalf("expected EOF for nil writer, got %v", err) 67 | } 68 | } 69 | 70 | func TestLargeDataTransfer(t *testing.T) { 71 | // Create large test data 72 | largeData := make([]byte, 1<<20) // 1MB 73 | for i := range largeData { 74 | largeData[i] = byte(i % 256) 75 | } 76 | 77 | reader := bytes.NewReader(largeData) 78 | writer := &bytes.Buffer{} 79 | srw := NewSizeReadWriter(reader, writer) 80 | 81 | // Test reading in chunks 82 | buf := make([]byte, 64*1024) // 64KB chunks 83 | totalRead := 0 84 | for { 85 | n, err := srw.Read(buf) 86 | if err == io.EOF { 87 | break 88 | } 89 | if err != nil { 90 | t.Fatalf("unexpected read error: %v", err) 91 | } 92 | totalRead += n 93 | } 94 | 95 | if totalRead != len(largeData) { 96 | t.Fatalf("expected to read %d bytes, got %d", len(largeData), totalRead) 97 | } 98 | 99 | if size := srw.TotalSize(); size != uint64(len(largeData)) { 100 | t.Fatalf("expected size %d after large read, got %d", len(largeData), size) 101 | } 102 | 103 | // Test writing large data 104 | writer.Reset() 105 | totalWritten := 0 106 | reader = bytes.NewReader(largeData) 107 | for { 108 | n, err := io.CopyN(srw, reader, 64*1024) // Write in 64KB chunks 109 | if err == io.EOF { 110 | break 111 | } 112 | if err != nil { 113 | t.Fatalf("unexpected write error: %v", err) 114 | } 115 | totalWritten += int(n) 116 | } 117 | 118 | if totalWritten != len(largeData) { 119 | t.Fatalf("expected to write %d bytes, got %d", len(largeData), totalWritten) 120 | } 121 | 122 | expectedSize := uint64(len(largeData)) * 2 // Both read and write 123 | if size := srw.TotalSize(); size != expectedSize { 124 | t.Fatalf("expected total size %d after large read+write, got %d", expectedSize, size) 125 | } 126 | 127 | // Verify written data matches 128 | if !bytes.Equal(writer.Bytes(), largeData) { 129 | t.Fatal("written data does not match original data") 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /iowriter/callback.go: -------------------------------------------------------------------------------- 1 | package iowriter 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | ) 7 | 8 | // CallbackWriter is an io.Writer which calls a callback. 9 | type CallbackWriter struct { 10 | cb func(p []byte) (n int, err error) 11 | } 12 | 13 | // NewCallbackWriter creates a new CallbackWriter with the given callback function. 14 | func NewCallbackWriter(cb func(p []byte) (n int, err error)) *CallbackWriter { 15 | return &CallbackWriter{cb: cb} 16 | } 17 | 18 | // Write calls the callback function with the given byte slice. 19 | // It returns an error if the callback is not defined. 20 | func (w *CallbackWriter) Write(p []byte) (n int, err error) { 21 | if w.cb == nil { 22 | return 0, errors.New("writer cb is not defined") 23 | } 24 | return w.cb(p) 25 | } 26 | 27 | // _ is a type assertion 28 | var _ io.Writer = ((*CallbackWriter)(nil)) 29 | -------------------------------------------------------------------------------- /iowriter/callback_test.go: -------------------------------------------------------------------------------- 1 | package iowriter 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "testing" 7 | ) 8 | 9 | func TestCallbackWriter(t *testing.T) { 10 | t.Run("Write with valid callback", func(t *testing.T) { 11 | var buf bytes.Buffer 12 | cw := NewCallbackWriter(func(p []byte) (int, error) { 13 | return buf.Write(p) 14 | }) 15 | 16 | testData := []byte("Hello, World!") 17 | n, err := cw.Write(testData) 18 | if err != nil { 19 | t.Errorf("Expected no error, got %v", err) 20 | } 21 | if n != len(testData) { 22 | t.Errorf("Expected %d bytes written, got %d", len(testData), n) 23 | } 24 | if buf.String() != string(testData) { 25 | t.Errorf("Expected %q, got %q", string(testData), buf.String()) 26 | } 27 | }) 28 | 29 | t.Run("Write with nil callback", func(t *testing.T) { 30 | cw := &CallbackWriter{cb: nil} 31 | 32 | _, err := cw.Write([]byte("Test")) 33 | 34 | if err == nil { 35 | t.Error("Expected an error, got nil") 36 | } 37 | if err.Error() != "writer cb is not defined" { 38 | t.Errorf("Expected error message 'writer cb is not defined', got %q", err.Error()) 39 | } 40 | }) 41 | 42 | t.Run("Write with error-returning callback", func(t *testing.T) { 43 | expectedError := errors.New("test error") 44 | cw := NewCallbackWriter(func(p []byte) (int, error) { 45 | return 0, expectedError 46 | }) 47 | 48 | _, err := cw.Write([]byte("Test")) 49 | 50 | if err != expectedError { 51 | t.Errorf("Expected error %v, got %v", expectedError, err) 52 | } 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /js/fetch/LICENSE: -------------------------------------------------------------------------------- 1 | This sub-directory is adapted from wasm-fetch (MIT License). 2 | 3 | Upstream: https://github.com/marwan-at-work/wasm-fetch 4 | 5 | MIT License 6 | 7 | Copyright (c) 2024 Christian Stewart 8 | Copyright (c) 2020 Marwan Sulaiman 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all 18 | copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | SOFTWARE. -------------------------------------------------------------------------------- /js/fetch/README.md: -------------------------------------------------------------------------------- 1 | # Fetch 2 | 3 | Wrapper for the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) 4 | 5 | ## Upstream 6 | 7 | Upstream: https://github.com/marwan-at-work/wasm-fetch 8 | 9 | Forked here. This sub-directory (only) is licensed MIT. 10 | -------------------------------------------------------------------------------- /js/fetch/doc.go: -------------------------------------------------------------------------------- 1 | // Package fetch is a js fetch wrapper that avoids importing net/http. 2 | /* 3 | ctx, cancel := context.WithTimeout(context.Background(), time.Second) 4 | defer cancel() 5 | resp, err := fetch.Fetch("/some/api/call", &fetch.Opts{ 6 | Body: strings.NewReader(`{"one": "two"}`), 7 | Method: fetch.MethodPost, 8 | Signal: ctx, 9 | }) 10 | */ 11 | package fetch 12 | -------------------------------------------------------------------------------- /js/fetch/enums.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | 3 | package fetch 4 | 5 | // cache enums 6 | const ( 7 | CacheDefault = "default" 8 | CacheNoStore = "no-store" 9 | CacheReload = "reload" 10 | CacheNone = "no-cache" 11 | CacheForce = "force-cache" 12 | CacheOnlyIfCached = "only-if-cached" 13 | ) 14 | 15 | // credentials enums 16 | const ( 17 | CredentialsOmit = "omit" 18 | CredentialsSameOrigin = "same-origin" 19 | CredentialsInclude = "include" 20 | ) 21 | 22 | // Common HTTP methods. 23 | // 24 | // Unless otherwise noted, these are defined in RFC 7231 section 4.3. 25 | const ( 26 | MethodGet = "GET" 27 | MethodHead = "HEAD" 28 | MethodPost = "POST" 29 | MethodPut = "PUT" 30 | MethodPatch = "PATCH" // RFC 5789 31 | MethodDelete = "DELETE" 32 | MethodConnect = "CONNECT" 33 | MethodOptions = "OPTIONS" 34 | MethodTrace = "TRACE" 35 | ) 36 | 37 | // Mode enums 38 | const ( 39 | ModeSameOrigin = "same-origin" 40 | ModeNoCORS = "no-cors" 41 | ModeCORS = "cors" 42 | ModeNavigate = "navigate" 43 | ) 44 | 45 | // Redirect enums 46 | const ( 47 | RedirectFollow = "follow" 48 | RedirectError = "error" 49 | RedirectManual = "manual" 50 | ) 51 | 52 | // Referrer enums 53 | const ( 54 | ReferrerNone = "no-referrer" 55 | ReferrerClient = "client" 56 | ) 57 | 58 | // ReferrerPolicy enums 59 | const ( 60 | ReferrerPolicyNone = "no-referrer" 61 | ReferrerPolicyNoDowngrade = "no-referrer-when-downgrade" 62 | ReferrerPolicyOrigin = "origin" 63 | ReferrerPolicyCrossOrigin = "origin-when-cross-origin" 64 | ReferrerPolicyUnsafeURL = "unsafe-url" 65 | ) 66 | -------------------------------------------------------------------------------- /js/fetch/fetch_test.go: -------------------------------------------------------------------------------- 1 | //go:build js && webtests 2 | 3 | package fetch 4 | 5 | import ( 6 | "context" 7 | "encoding/json" 8 | "io" 9 | "strings" 10 | "testing" 11 | "time" 12 | ) 13 | 14 | func TestFetchHttpBin(t *testing.T) { 15 | t.Run("GET request", func(t *testing.T) { 16 | url := "https://httpbin.org/get" 17 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 18 | defer cancel() 19 | 20 | opts := &Opts{ 21 | Method: "GET", 22 | Signal: ctx, 23 | } 24 | 25 | resp, err := Fetch(url, opts) 26 | if err != nil { 27 | t.Fatalf("Failed to fetch: %v", err) 28 | } 29 | defer resp.Body.Close() 30 | 31 | if resp.StatusCode != 200 { 32 | t.Errorf("Expected status code 200, got %d", resp.StatusCode) 33 | } 34 | 35 | body, err := io.ReadAll(resp.Body) 36 | if err != nil { 37 | t.Fatalf("Failed to read response body: %v", err) 38 | } 39 | 40 | var result map[string]interface{} 41 | err = json.Unmarshal(body, &result) 42 | if err != nil { 43 | t.Fatalf("Failed to unmarshal JSON: %v", err) 44 | } 45 | 46 | url, ok := result["url"].(string) 47 | if !ok { 48 | t.Fatalf("Response does not contain a 'url' field of type string") 49 | } 50 | 51 | if url != "https://httpbin.org/get" { 52 | t.Errorf("Expected URL to be 'https://httpbin.org/get', got '%s'", url) 53 | } 54 | 55 | t.Logf("Received response from URL: %s", url) 56 | }) 57 | 58 | t.Run("POST request with body", func(t *testing.T) { 59 | url := "https://httpbin.org/post" 60 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 61 | defer cancel() 62 | 63 | postBody := strings.NewReader(`{"key": "value"}`) 64 | opts := &Opts{ 65 | Method: "POST", 66 | Signal: ctx, 67 | Body: postBody, 68 | Header: Header{ 69 | "Content-Type": []string{"application/json"}, 70 | }, 71 | } 72 | 73 | resp, err := Fetch(url, opts) 74 | if err != nil { 75 | t.Fatalf("Failed to fetch: %v", err) 76 | } 77 | defer resp.Body.Close() 78 | 79 | if resp.StatusCode != 200 { 80 | t.Errorf("Expected status code 200, got %d", resp.StatusCode) 81 | } 82 | 83 | body, err := io.ReadAll(resp.Body) 84 | if err != nil { 85 | t.Fatalf("Failed to read response body: %v", err) 86 | } 87 | 88 | var result map[string]interface{} 89 | err = json.Unmarshal(body, &result) 90 | if err != nil { 91 | t.Fatalf("Failed to unmarshal JSON: %v", err) 92 | } 93 | 94 | jsonData, ok := result["json"].(map[string]interface{}) 95 | if !ok { 96 | t.Fatalf("Response does not contain a 'json' field of type map[string]interface{}") 97 | } 98 | 99 | value, ok := jsonData["key"].(string) 100 | if !ok || value != "value" { 101 | t.Errorf("Expected JSON data to contain {'key': 'value'}, got %v", jsonData) 102 | } 103 | 104 | t.Logf("Received response with correct POST body") 105 | }) 106 | } 107 | 108 | func TestMerge(t *testing.T) { 109 | t.Run("CommonOpts.Merge", func(t *testing.T) { 110 | base := &CommonOpts{ 111 | Mode: "cors", 112 | Credentials: "include", 113 | Cache: "no-cache", 114 | } 115 | other := &CommonOpts{ 116 | Mode: "no-cors", 117 | ReferrerPolicy: "no-referrer", 118 | KeepAlive: &[]bool{true}[0], 119 | } 120 | 121 | base.Merge(other) 122 | 123 | if base.Mode != "no-cors" { 124 | t.Errorf("Expected Mode to be 'no-cors', got '%s'", base.Mode) 125 | } 126 | if base.Credentials != "include" { 127 | t.Errorf("Expected Credentials to be 'include', got '%s'", base.Credentials) 128 | } 129 | if base.Cache != "no-cache" { 130 | t.Errorf("Expected Cache to be 'no-cache', got '%s'", base.Cache) 131 | } 132 | if base.ReferrerPolicy != "no-referrer" { 133 | t.Errorf("Expected ReferrerPolicy to be 'no-referrer', got '%s'", base.ReferrerPolicy) 134 | } 135 | if base.KeepAlive == nil || *base.KeepAlive != true { 136 | t.Errorf("Expected KeepAlive to be true, got %v", base.KeepAlive) 137 | } 138 | }) 139 | 140 | t.Run("Opts.Merge", func(t *testing.T) { 141 | base := &Opts{ 142 | Method: "GET", 143 | Header: Header{"Content-Type": []string{"application/json"}}, 144 | CommonOpts: CommonOpts{ 145 | Mode: "cors", 146 | }, 147 | } 148 | other := &Opts{ 149 | Method: "POST", 150 | Header: Header{"Authorization": []string{"Bearer token"}}, 151 | Body: strings.NewReader("test body"), 152 | CommonOpts: CommonOpts{ 153 | Credentials: "include", 154 | }, 155 | } 156 | 157 | base.Merge(other) 158 | 159 | if base.Method != "POST" { 160 | t.Errorf("Expected Method to be 'POST', got '%s'", base.Method) 161 | } 162 | if len(base.Header) != 2 { 163 | t.Errorf("Expected 2 headers, got %d", len(base.Header)) 164 | } 165 | if base.Header.Get("Content-Type") != "application/json" { 166 | t.Errorf("Expected Content-Type header to be 'application/json', got '%s'", base.Header.Get("Content-Type")) 167 | } 168 | if base.Header.Get("Authorization") != "Bearer token" { 169 | t.Errorf("Expected Authorization header to be 'Bearer token', got '%s'", base.Header.Get("Authorization")) 170 | } 171 | if base.Body == nil { 172 | t.Error("Expected Body to be set, got nil") 173 | } 174 | if base.CommonOpts.Mode != "cors" { 175 | t.Errorf("Expected Mode to be 'cors', got '%s'", base.CommonOpts.Mode) 176 | } 177 | if base.CommonOpts.Credentials != "include" { 178 | t.Errorf("Expected Credentials to be 'include', got '%s'", base.CommonOpts.Credentials) 179 | } 180 | }) 181 | } 182 | -------------------------------------------------------------------------------- /js/fetch/header.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | 3 | package fetch 4 | 5 | import ( 6 | "io" 7 | "net/textproto" 8 | "slices" 9 | "sort" 10 | "strings" 11 | "sync" 12 | ) 13 | 14 | // A Header represents the key-value pairs in an HTTP header. 15 | type Header map[string][]string 16 | 17 | // Add adds the key, value pair to the header. 18 | // It appends to any existing values associated with key. 19 | func (h Header) Add(key, value string) { 20 | textproto.MIMEHeader(h).Add(key, value) 21 | } 22 | 23 | // Set sets the header entries associated with key to 24 | // the single element value. It replaces any existing 25 | // values associated with key. 26 | func (h Header) Set(key, value string) { 27 | textproto.MIMEHeader(h).Set(key, value) 28 | } 29 | 30 | // Get gets the first value associated with the given key. 31 | // It is case insensitive; textproto.CanonicalMIMEHeaderKey is used 32 | // to canonicalize the provided key. 33 | // If there are no values associated with the key, Get returns "". 34 | // To access multiple values of a key, or to use non-canonical keys, 35 | // access the map directly. 36 | func (h Header) Get(key string) string { 37 | return textproto.MIMEHeader(h).Get(key) 38 | } 39 | 40 | // get is like Get, but key must already be in CanonicalHeaderKey form. 41 | func (h Header) get(key string) string { 42 | if v := h[key]; len(v) > 0 { 43 | return v[0] 44 | } 45 | return "" 46 | } 47 | 48 | // Del deletes the values associated with key. 49 | func (h Header) Del(key string) { 50 | textproto.MIMEHeader(h).Del(key) 51 | } 52 | 53 | // Write writes a header in wire format. 54 | func (h Header) Write(w io.Writer) error { 55 | return h.write(w) 56 | } 57 | 58 | func (h Header) write(w io.Writer) error { 59 | return h.writeSubset(w, nil) 60 | } 61 | 62 | // Clone returns a deep copy of the header. 63 | func (h Header) Clone() Header { 64 | h2 := make(Header, len(h)) 65 | for k, vv := range h { 66 | h2[k] = slices.Clone(vv) 67 | } 68 | return h2 69 | } 70 | 71 | var headerNewlineToSpace = strings.NewReplacer("\n", " ", "\r", " ") 72 | 73 | type writeStringer interface { 74 | WriteString(string) (int, error) 75 | } 76 | 77 | // stringWriter implements WriteString on a Writer. 78 | type stringWriter struct { 79 | w io.Writer 80 | } 81 | 82 | func (w stringWriter) WriteString(s string) (n int, err error) { 83 | return w.w.Write([]byte(s)) 84 | } 85 | 86 | type keyValues struct { 87 | key string 88 | values []string 89 | } 90 | 91 | // A headerSorter implements sort.Interface by sorting a []keyValues 92 | // by key. It's used as a pointer, so it can fit in a sort.Interface 93 | // interface value without allocation. 94 | type headerSorter struct { 95 | kvs []keyValues 96 | } 97 | 98 | func (s *headerSorter) Len() int { return len(s.kvs) } 99 | func (s *headerSorter) Swap(i, j int) { s.kvs[i], s.kvs[j] = s.kvs[j], s.kvs[i] } 100 | func (s *headerSorter) Less(i, j int) bool { return s.kvs[i].key < s.kvs[j].key } 101 | 102 | var headerSorterPool = sync.Pool{ 103 | New: func() interface{} { return new(headerSorter) }, 104 | } 105 | 106 | // sortedKeyValues returns h's keys sorted in the returned kvs 107 | // slice. The headerSorter used to sort is also returned, for possible 108 | // return to headerSorterCache. 109 | func (h Header) sortedKeyValues(exclude map[string]bool) (kvs []keyValues, hs *headerSorter) { 110 | hs = headerSorterPool.Get().(*headerSorter) 111 | if cap(hs.kvs) < len(h) { 112 | hs.kvs = make([]keyValues, 0, len(h)) 113 | } 114 | kvs = hs.kvs[:0] 115 | for k, vv := range h { 116 | if !exclude[k] { 117 | kvs = append(kvs, keyValues{k, vv}) 118 | } 119 | } 120 | hs.kvs = kvs 121 | sort.Sort(hs) 122 | return kvs, hs 123 | } 124 | 125 | // WriteSubset writes a header in wire format. 126 | // If exclude is not nil, keys where exclude[key] == true are not written. 127 | func (h Header) WriteSubset(w io.Writer, exclude map[string]bool) error { 128 | return h.writeSubset(w, exclude) 129 | } 130 | 131 | func (h Header) writeSubset(w io.Writer, exclude map[string]bool) error { 132 | ws, ok := w.(writeStringer) 133 | if !ok { 134 | ws = stringWriter{w} 135 | } 136 | kvs, sorter := h.sortedKeyValues(exclude) 137 | for _, kv := range kvs { 138 | for _, v := range kv.values { 139 | v = headerNewlineToSpace.Replace(v) 140 | v = textproto.TrimString(v) 141 | for _, s := range []string{kv.key, ": ", v, "\r\n"} { 142 | if _, err := ws.WriteString(s); err != nil { 143 | headerSorterPool.Put(sorter) 144 | return err 145 | } 146 | } 147 | } 148 | } 149 | headerSorterPool.Put(sorter) 150 | return nil 151 | } 152 | 153 | // CanonicalHeaderKey returns the canonical format of the 154 | // header key s. The canonicalization converts the first 155 | // letter and any letter following a hyphen to upper case; 156 | // the rest are converted to lowercase. For example, the 157 | // canonical key for "accept-encoding" is "Accept-Encoding". 158 | // If s contains a space or invalid header field bytes, it is 159 | // returned without modifications. 160 | func CanonicalHeaderKey(s string) string { return textproto.CanonicalMIMEHeaderKey(s) } 161 | -------------------------------------------------------------------------------- /js/readable-stream/stream.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | 3 | package stream 4 | 5 | import ( 6 | "errors" 7 | "io" 8 | "sync" 9 | "syscall/js" 10 | ) 11 | 12 | // ReadableStream implements io.ReadCloser for the response body. 13 | type ReadableStream struct { 14 | stream js.Value 15 | reader js.Value 16 | closed bool 17 | mu sync.Mutex 18 | readError error 19 | buffer []byte 20 | } 21 | 22 | func NewReadableStream(stream js.Value) *ReadableStream { 23 | return &ReadableStream{ 24 | stream: stream, 25 | reader: stream.Call("getReader"), 26 | } 27 | } 28 | 29 | func (b *ReadableStream) Read(p []byte) (n int, err error) { 30 | b.mu.Lock() 31 | defer b.mu.Unlock() 32 | 33 | if b.closed { 34 | return 0, io.EOF 35 | } 36 | 37 | if len(b.buffer) > 0 { 38 | n = copy(p, b.buffer) 39 | b.buffer = b.buffer[n:] 40 | return n, nil 41 | } 42 | 43 | resultChan := make(chan struct{}, 2) 44 | var result js.Value 45 | 46 | success := js.FuncOf(func(this js.Value, args []js.Value) interface{} { 47 | result = args[0] 48 | resultChan <- struct{}{} 49 | return nil 50 | }) 51 | defer success.Release() 52 | 53 | failure := js.FuncOf(func(this js.Value, args []js.Value) interface{} { 54 | b.readError = errors.New(args[0].Get("message").String()) 55 | resultChan <- struct{}{} 56 | return nil 57 | }) 58 | defer failure.Release() 59 | 60 | b.reader.Call("read").Call("then", success).Call("catch", failure) 61 | <-resultChan 62 | 63 | if b.readError != nil { 64 | return 0, b.readError 65 | } 66 | 67 | if result.IsUndefined() || result.IsNull() { 68 | b.closed = true 69 | return 0, io.EOF 70 | } 71 | 72 | done := result.Get("done").Bool() 73 | if done { 74 | b.closed = true 75 | return 0, io.EOF 76 | } 77 | 78 | value := result.Get("value") 79 | if value.IsUndefined() || value.IsNull() { 80 | b.closed = true 81 | return 0, io.EOF 82 | } 83 | 84 | valueLength := value.Length() 85 | if valueLength == 0 { 86 | return 0, nil 87 | } 88 | 89 | b.buffer = make([]byte, valueLength) 90 | js.CopyBytesToGo(b.buffer, value) 91 | 92 | n = copy(p, b.buffer) 93 | b.buffer = b.buffer[n:] 94 | 95 | if len(b.buffer) == 0 { 96 | b.buffer = nil 97 | } 98 | 99 | return n, nil 100 | } 101 | 102 | func (b *ReadableStream) Close() error { 103 | b.mu.Lock() 104 | defer b.mu.Unlock() 105 | 106 | if !b.closed { 107 | b.closed = true 108 | b.reader.Call("cancel") 109 | } 110 | 111 | return nil 112 | } 113 | -------------------------------------------------------------------------------- /js/readable-stream/stream_test.go: -------------------------------------------------------------------------------- 1 | //go:build js && webtests 2 | 3 | package stream 4 | 5 | import ( 6 | "io" 7 | "strings" 8 | "syscall/js" 9 | "testing" 10 | ) 11 | 12 | func TestReadableStream(t *testing.T) { 13 | t.Run("Read entire stream", func(t *testing.T) { 14 | mockStream := newMockStream("Hello, World!") 15 | reader := NewReadableStream(mockStream) 16 | 17 | result, err := io.ReadAll(reader) 18 | if err != nil { 19 | t.Fatalf("Failed to read from BodyReader: %v", err) 20 | } 21 | 22 | if string(result) != "Hello, World!" { 23 | t.Errorf("Expected 'Hello, World!', got '%s'", string(result)) 24 | } 25 | }) 26 | 27 | t.Run("Read in chunks", func(t *testing.T) { 28 | mockStream := newMockStream("Hello, World!") 29 | reader := NewReadableStream(mockStream) 30 | 31 | result := make([]byte, 0, 13) 32 | buf := make([]byte, 5) 33 | for { 34 | n, err := reader.Read(buf) 35 | if err == io.EOF { 36 | break 37 | } 38 | if err != nil { 39 | t.Fatalf("Failed to read chunk: %v", err) 40 | } 41 | result = append(result, buf[:n]...) 42 | } 43 | 44 | if string(result) != "Hello, World!" { 45 | t.Errorf("Expected 'Hello, World!', got '%s'", string(result)) 46 | } 47 | }) 48 | 49 | t.Run("Close stream", func(t *testing.T) { 50 | mockStream := newMockStream("Hello, World!") 51 | reader := NewReadableStream(mockStream) 52 | 53 | err := reader.Close() 54 | if err != nil { 55 | t.Fatalf("Failed to close BodyReader: %v", err) 56 | } 57 | 58 | _, err = reader.Read(make([]byte, 1)) 59 | if err != io.EOF { 60 | t.Errorf("Expected EOF after closing, got: %v", err) 61 | } 62 | }) 63 | } 64 | 65 | func newMockStream(content string) js.Value { 66 | return js.Global().Get("ReadableStream").New(map[string]interface{}{ 67 | "start": js.FuncOf(func(this js.Value, args []js.Value) interface{} { 68 | controller := args[0] 69 | go func() { 70 | for _, chunk := range strings.Split(content, "") { 71 | controller.Call("enqueue", js.Global().Get("TextEncoder").New().Call("encode", chunk)) 72 | } 73 | controller.Call("close") 74 | }() 75 | return nil 76 | }), 77 | }) 78 | } 79 | -------------------------------------------------------------------------------- /keyed/keyed-opts.go: -------------------------------------------------------------------------------- 1 | package keyed 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/aperturerobotics/util/backoff" 7 | cbackoff "github.com/aperturerobotics/util/backoff/cbackoff" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // Option is an option for a Keyed instance. 12 | type Option[K comparable, V any] interface { 13 | // ApplyToKeyed applies the option to the Keyed. 14 | ApplyToKeyed(k *Keyed[K, V]) 15 | } 16 | 17 | type option[K comparable, V any] struct { 18 | cb func(k *Keyed[K, V]) 19 | } 20 | 21 | // newOption constructs a new option. 22 | func newOption[K comparable, V any](cb func(k *Keyed[K, V])) *option[K, V] { 23 | return &option[K, V]{cb: cb} 24 | } 25 | 26 | // ApplyToKeyed applies the option to the Keyed instance. 27 | func (o *option[K, V]) ApplyToKeyed(k *Keyed[K, V]) { 28 | if o.cb != nil { 29 | o.cb(k) 30 | } 31 | } 32 | 33 | // WithReleaseDelay adds a delay after removing a key before canceling the routine. 34 | func WithReleaseDelay[K comparable, V any](delay time.Duration) Option[K, V] { 35 | if delay < 0 { 36 | delay *= -1 37 | } 38 | return newOption(func(k *Keyed[K, V]) { 39 | k.releaseDelay = delay 40 | }) 41 | } 42 | 43 | // WithRetry adds a retry after a routine exits with an error. 44 | // 45 | // If the backoff config is nil, disables retry. 46 | func WithRetry[K comparable, V any](bo *backoff.Backoff) Option[K, V] { 47 | return newOption(func(k *Keyed[K, V]) { 48 | if bo == nil { 49 | k.backoffFactory = nil 50 | } else { 51 | k.backoffFactory = func(k K) cbackoff.BackOff { 52 | return bo.Construct() 53 | } 54 | } 55 | }) 56 | } 57 | 58 | // WithBackoff adds a function to call to construct a backoff. 59 | // 60 | // If the function returns nil, disables retry. 61 | func WithBackoff[K comparable, V any](cb func(k K) cbackoff.BackOff) Option[K, V] { 62 | return newOption(func(k *Keyed[K, V]) { 63 | k.backoffFactory = cb 64 | }) 65 | } 66 | 67 | // WithExitCb adds a callback after a routine exits. 68 | func WithExitCb[K comparable, V any](cb func(key K, routine Routine, data V, err error)) Option[K, V] { 69 | return newOption(func(k *Keyed[K, V]) { 70 | k.exitedCbs = append(k.exitedCbs, cb) 71 | }) 72 | } 73 | 74 | // WithExitLogger adds a exited callback which logs information about the exit. 75 | func WithExitLogger[K comparable, V any](le *logrus.Entry) Option[K, V] { 76 | return WithExitCb(NewLogExitedCallback[K, V](le)) 77 | } 78 | 79 | // WithExitLoggerWithName adds a exited callback which logs information about the exit with a name string. 80 | func WithExitLoggerWithName[K comparable, V any](le *logrus.Entry, name string) Option[K, V] { 81 | return WithExitCb(NewLogExitedCallbackWithName[K, V](le, name)) 82 | } 83 | 84 | // WithExitLoggerWithNameFn adds a exited callback which logs information about the exit with a name function. 85 | func WithExitLoggerWithNameFn[K comparable, V any](le *logrus.Entry, nameFn func(key K) string) Option[K, V] { 86 | return WithExitCb(NewLogExitedCallbackWithNameFn[K, V](le, nameFn)) 87 | } 88 | -------------------------------------------------------------------------------- /keyed/log-exited.go: -------------------------------------------------------------------------------- 1 | package keyed 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sirupsen/logrus" 7 | ) 8 | 9 | // NewLogExitedCallback returns a ExitedCb which logs when a controller exited. 10 | func NewLogExitedCallback[K comparable, V any](le *logrus.Entry) func(key K, routine Routine, data V, err error) { 11 | return func(key K, routine Routine, data V, err error) { 12 | if err != nil && err != context.Canceled { 13 | le.WithError(err).Warnf("keyed: routine exited: %v", key) 14 | } else { 15 | le.Debugf("keyed: routine exited: %v", key) 16 | } 17 | } 18 | } 19 | 20 | // NewLogExitedCallbackWithName returns a ExitedCb which logs when a controller exited with a name instead of the key. 21 | func NewLogExitedCallbackWithName[K comparable, V any](le *logrus.Entry, name string) func(key K, routine Routine, data V, err error) { 22 | return func(key K, routine Routine, data V, err error) { 23 | if err != nil && err != context.Canceled { 24 | le.WithError(err).Warnf("keyed: routine exited: %v", name) 25 | } else { 26 | le.Debugf("keyed: routine exited: %v", name) 27 | } 28 | } 29 | } 30 | 31 | // NewLogExitedCallbackWithNameFn returns a ExitedCb which logs when a controller exited with a name function. 32 | func NewLogExitedCallbackWithNameFn[K comparable, V any](le *logrus.Entry, nameFn func(key K) string) func(key K, routine Routine, data V, err error) { 33 | if nameFn == nil { 34 | return NewLogExitedCallback[K, V](le) 35 | } 36 | 37 | return func(key K, routine Routine, data V, err error) { 38 | if err != nil && err != context.Canceled { 39 | le.WithError(err).Warnf("keyed: routine exited: %v", nameFn(key)) 40 | } else { 41 | le.Debugf("keyed: routine exited: %v", nameFn(key)) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /keyed/routine.go: -------------------------------------------------------------------------------- 1 | package keyed 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/aperturerobotics/util/backoff" 8 | cbackoff "github.com/aperturerobotics/util/backoff/cbackoff" 9 | ) 10 | 11 | // runningRoutine tracks a running routine 12 | type runningRoutine[K comparable, V any] struct { 13 | // k is the keyed instance 14 | k *Keyed[K, V] 15 | // key is the key for this routine 16 | key K 17 | 18 | // fields guarded by k.mtx 19 | // ctx is the context 20 | ctx context.Context 21 | // ctxCancel cancels the context 22 | // if nil, not running 23 | ctxCancel context.CancelFunc 24 | // exitedCh is closed when the routine running with ctx exits 25 | // may be nil if ctx == nil 26 | exitedCh <-chan struct{} 27 | // routine is the routine callback 28 | routine Routine 29 | // data is the associated routine data 30 | data V 31 | // err is the error if any 32 | err error 33 | // success indicates the routine succeeded 34 | success bool 35 | // exited indicates the routine exited 36 | exited bool 37 | 38 | // deferRemove is set if we are waiting to remove this. 39 | deferRemove *time.Timer 40 | 41 | // retryBo is the retry backoff if retrying is enabled. 42 | retryBo cbackoff.BackOff 43 | // deferRetry is set if we are waiting to retry this. 44 | deferRetry *time.Timer 45 | } 46 | 47 | // newRunningRoutine constructs a new runningRoutine 48 | func newRunningRoutine[K comparable, V any]( 49 | k *Keyed[K, V], 50 | key K, 51 | routine Routine, 52 | data V, 53 | backoffFactory func(k K) cbackoff.BackOff, 54 | ) *runningRoutine[K, V] { 55 | var backoff cbackoff.BackOff 56 | if backoffFactory != nil { 57 | backoff = backoffFactory(key) 58 | } 59 | return &runningRoutine[K, V]{ 60 | k: k, 61 | key: key, 62 | routine: routine, 63 | data: data, 64 | retryBo: backoff, 65 | } 66 | } 67 | 68 | // start starts or restarts the routine (if not running). 69 | // expects k.mtx to be locked by caller 70 | // if waitCh != nil, waits for waitCh to be closed before fully starting. 71 | // if forceRestart is set, cancels the existing routine. 72 | func (r *runningRoutine[K, V]) start(ctx context.Context, waitCh <-chan struct{}, forceRestart bool) { 73 | if (!forceRestart && r.success) || r.routine == nil { 74 | return 75 | } 76 | if !forceRestart && r.ctx != nil && !r.exited && r.ctx.Err() == nil { 77 | // routine is still running 78 | return 79 | } 80 | if r.deferRetry != nil { 81 | // cancel retrying this key 82 | _ = r.deferRetry.Stop() 83 | r.deferRetry = nil 84 | } 85 | if r.ctxCancel != nil { 86 | r.ctxCancel() 87 | } 88 | exitedCh := make(chan struct{}) 89 | r.err = nil 90 | r.success, r.exited = false, false 91 | r.exitedCh = exitedCh 92 | r.ctx, r.ctxCancel = context.WithCancel(ctx) 93 | go r.execute(r.ctx, r.ctxCancel, exitedCh, waitCh) 94 | } 95 | 96 | // execute executes the routine. 97 | func (r *runningRoutine[K, V]) execute( 98 | ctx context.Context, 99 | cancel context.CancelFunc, 100 | exitedCh chan struct{}, 101 | waitCh <-chan struct{}, 102 | ) { 103 | var err error 104 | if waitCh != nil { 105 | select { 106 | case <-ctx.Done(): 107 | err = context.Canceled 108 | case <-waitCh: 109 | } 110 | } else if err = ctx.Err(); err != nil { 111 | err = context.Canceled 112 | } 113 | 114 | if err == nil { 115 | err = r.routine(ctx) 116 | } 117 | cancel() 118 | close(exitedCh) 119 | 120 | r.k.mtx.Lock() 121 | if r.ctx == ctx { 122 | r.err = err 123 | r.success = err == nil 124 | r.exited = true 125 | r.exitedCh = nil 126 | if r.retryBo != nil { 127 | if r.deferRetry != nil { 128 | r.deferRetry.Stop() 129 | r.deferRetry = nil 130 | } 131 | if r.success { 132 | r.retryBo.Reset() 133 | } else if r.k.routines[r.key] == r { 134 | dur := r.retryBo.NextBackOff() 135 | if dur != backoff.Stop { 136 | r.deferRetry = time.AfterFunc(dur, func() { 137 | r.k.mtx.Lock() 138 | if r.k.ctx != nil && r.k.routines[r.key] == r && r.exited { 139 | r.start(r.k.ctx, r.exitedCh, true) 140 | } 141 | r.k.mtx.Unlock() 142 | }) 143 | } 144 | } 145 | } 146 | for i := len(r.k.exitedCbs) - 1; i >= 0; i-- { 147 | // run after unlocking mtx 148 | defer (r.k.exitedCbs[i])(r.key, r.routine, r.data, r.err) 149 | } 150 | } 151 | r.k.mtx.Unlock() 152 | } 153 | 154 | // remove is called when the routine is removed / canceled. 155 | // expects r.k.mtx to be locked 156 | func (r *runningRoutine[K, V]) remove() { 157 | if r.deferRemove != nil { 158 | return 159 | } 160 | removeNow := func() { 161 | if r.ctxCancel != nil { 162 | r.ctxCancel() 163 | } 164 | if r.deferRetry != nil { 165 | // cancel retrying this key 166 | _ = r.deferRetry.Stop() 167 | r.deferRetry = nil 168 | } 169 | delete(r.k.routines, r.key) 170 | } 171 | if r.k.releaseDelay == 0 || (r.exited && !r.success) { 172 | removeNow() 173 | return 174 | } 175 | 176 | timerCb := func() { 177 | r.k.mtx.Lock() 178 | if r.k.routines[r.key] == r && r.deferRemove != nil { 179 | _ = r.deferRemove.Stop() 180 | r.deferRemove = nil 181 | removeNow() 182 | } 183 | r.k.mtx.Unlock() 184 | } 185 | r.deferRemove = time.AfterFunc(r.k.releaseDelay, timerCb) 186 | } 187 | -------------------------------------------------------------------------------- /linkedlist/linkedlist.go: -------------------------------------------------------------------------------- 1 | package linkedlist 2 | 3 | import "sync" 4 | 5 | // LinkedList implements a pointer-linked list with a head and tail. 6 | // 7 | // The empty value is a valid empty linked list. 8 | type LinkedList[T any] struct { 9 | // mtx guards below fields 10 | mtx sync.RWMutex 11 | // head is the current head elem 12 | // least-recently-added 13 | head *linkedListElem[T] 14 | // tail is the current tail item 15 | // most-recently-added 16 | tail *linkedListElem[T] 17 | } 18 | 19 | // linkedListElem is an elem in the linked list. 20 | type linkedListElem[T any] struct { 21 | // next is the next element in the list 22 | next *linkedListElem[T] 23 | // val is the value 24 | val T 25 | } 26 | 27 | // NewLinkedList constructs a new LinkedList. 28 | func NewLinkedList[T any](elems ...T) *LinkedList[T] { 29 | ll := &LinkedList[T]{} 30 | for _, elem := range elems { 31 | ll.pushElem(elem) 32 | } 33 | return ll 34 | } 35 | 36 | // Push pushes a value to the end of the linked list. 37 | func (l *LinkedList[T]) Push(val T) { 38 | l.mtx.Lock() 39 | l.pushElem(val) 40 | l.mtx.Unlock() 41 | } 42 | 43 | // PushFront pushes a value to the front of the linked list. 44 | // It will be returned next for Pop or Peek. 45 | func (l *LinkedList[T]) PushFront(val T) { 46 | l.mtx.Lock() 47 | elem := &linkedListElem[T]{val: val} 48 | if l.head != nil { 49 | elem.next = l.head 50 | } else { 51 | l.tail = elem 52 | } 53 | l.head = elem 54 | l.mtx.Unlock() 55 | } 56 | 57 | // Peek peeks the head of the linked list. 58 | func (l *LinkedList[T]) Peek() (T, bool) { 59 | l.mtx.Lock() 60 | var val T 61 | exists := l.head != nil 62 | if exists { 63 | val = l.head.val 64 | } 65 | l.mtx.Unlock() 66 | return val, exists 67 | } 68 | 69 | // IsEmpty checks if the linked list is empty. 70 | func (l *LinkedList[T]) IsEmpty() bool { 71 | l.mtx.Lock() 72 | empty := l.head == nil 73 | l.mtx.Unlock() 74 | return empty 75 | } 76 | 77 | // PeekTail peeks the tail of the linked list. 78 | func (l *LinkedList[T]) PeekTail() (T, bool) { 79 | l.mtx.Lock() 80 | var val T 81 | exists := l.tail != nil 82 | if exists { 83 | val = l.tail.val 84 | } 85 | l.mtx.Unlock() 86 | return val, exists 87 | } 88 | 89 | // Pop dequeues the head of the linked list. 90 | func (l *LinkedList[T]) Pop() (T, bool) { 91 | l.mtx.Lock() 92 | var val T 93 | exists := l.head != nil 94 | if exists { 95 | val = l.head.val 96 | if l.head.next != nil { 97 | l.head = l.head.next 98 | } else { 99 | l.head = nil 100 | l.tail = nil 101 | } 102 | } 103 | l.mtx.Unlock() 104 | return val, exists 105 | } 106 | 107 | // Reset clears the linked list. 108 | func (l *LinkedList[T]) Reset() { 109 | l.mtx.Lock() 110 | l.head, l.tail = nil, nil 111 | l.mtx.Unlock() 112 | } 113 | 114 | // pushElem pushes an element to the list while mtx is locked. 115 | func (l *LinkedList[T]) pushElem(val T) { 116 | elem := &linkedListElem[T]{val: val} 117 | if l.tail == nil { 118 | l.head = elem 119 | } else { 120 | l.tail.next = elem 121 | } 122 | l.tail = elem 123 | } 124 | -------------------------------------------------------------------------------- /linkedlist/linkedlist_test.go: -------------------------------------------------------------------------------- 1 | package linkedlist 2 | 3 | import "testing" 4 | 5 | // TestLinkedList tests the linked list. 6 | func TestLinkedList(t *testing.T) { 7 | ll := NewLinkedList(0, 1, 2, 3, 4) 8 | v, ok := ll.Peek() 9 | if v != 0 || !ok { 10 | t.Fail() 11 | } 12 | v, ok = ll.PeekTail() 13 | if v != 4 || !ok { 14 | t.Fail() 15 | } 16 | ll.Push(5) 17 | v, ok = ll.PeekTail() 18 | if v != 5 || !ok { 19 | t.Fail() 20 | } 21 | v, ok = ll.Pop() 22 | if v != 0 || !ok { 23 | t.Fail() 24 | } 25 | ll.Push(6) 26 | v, ok = ll.Pop() 27 | if v != 1 || !ok { 28 | t.Fail() 29 | } 30 | v, ok = ll.Peek() 31 | if v != 2 || !ok { 32 | t.Fail() 33 | } 34 | v, ok = ll.PeekTail() 35 | if v != 6 || !ok { 36 | t.Fail() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /memo/memo.go: -------------------------------------------------------------------------------- 1 | package memo 2 | 3 | import ( 4 | "sync/atomic" 5 | ) 6 | 7 | // MemoizeFunc memoizes the given function. 8 | func MemoizeFunc[T any](fn func() (T, error)) func() (T, error) { 9 | var started atomic.Bool 10 | done := make(chan struct{}) 11 | var result T 12 | var doneErr error 13 | return func() (T, error) { 14 | if !started.Swap(true) { 15 | defer close(done) 16 | result, doneErr = fn() 17 | return result, doneErr 18 | } else { 19 | <-done 20 | return result, doneErr 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /memo/memo_test.go: -------------------------------------------------------------------------------- 1 | package memo 2 | 3 | import ( 4 | "sync/atomic" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | // TestMemoizeFunc tests memoizing a function. 10 | func TestMemoizeFunc(t *testing.T) { 11 | var n int 12 | complete := make(chan struct{}) 13 | fn := func() (int, error) { 14 | n++ 15 | <-complete 16 | return n, nil 17 | } 18 | memoFn := MemoizeFunc(fn) 19 | for i := 0; i < 10; i++ { 20 | go func() { 21 | _, _ = memoFn() 22 | }() 23 | } 24 | var returned atomic.Bool 25 | go func() { 26 | _, _ = memoFn() 27 | returned.Store(true) 28 | }() 29 | <-time.After(time.Millisecond * 50) 30 | if returned.Load() { 31 | t.Fail() 32 | } 33 | close(complete) 34 | <-time.After(time.Millisecond * 50) 35 | res, err := memoFn() 36 | if err != nil || res != 1 { 37 | t.Fail() 38 | } 39 | if !returned.Load() { 40 | t.Fail() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@aperturerobotics/util", 3 | "description": "Utilities and experimental data structures.", 4 | "version": "0.0.0", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Aperture Robotics LLC.", 8 | "email": "support@aperture.us", 9 | "url": "http://aperture.us" 10 | }, 11 | "contributors": [ 12 | { 13 | "name": "Christian Stewart", 14 | "email": "christian@aperture.us", 15 | "url": "http://github.com/paralin" 16 | } 17 | ], 18 | "repository": { 19 | "url": "git+ssh://git@github.com/aperturerobotics/util.git" 20 | }, 21 | "type": "module", 22 | "scripts": { 23 | "build": "tsc --project tsconfig.json --noEmit false --outDir ./dist/", 24 | "check": "npm run typecheck", 25 | "deps": "depcheck --ignore-patterns=.eslintrc.cjs,package.json --ignores depcheck,prettier,typescript,starpc,@go/github.com,rimraf,@aptre/common", 26 | "typecheck": "tsc --noEmit", 27 | "codegen": "npm run gen", 28 | "ci": "npm run build && npm run lint:js && npm run lint:go", 29 | "format": "npm run format:go && npm run format:js && npm run format:config", 30 | "format:config": "prettier --write tsconfig.json package.json", 31 | "format:go": "make format", 32 | "format:js": "npm run format:js:changed", 33 | "format:js:changed": "git diff --name-only --diff-filter=d HEAD | grep '\\(\\.ts\\|\\.tsx\\|\\.html\\|\\.css\\|\\.scss\\)$' | xargs -I {} prettier --write {}", 34 | "format:js:all": "prettier --write './!(vendor|dist)/**/(*.ts|*.tsx|*.js|*.html|*.css)'", 35 | "gen": "make genproto", 36 | "test": "make test && npm run check && npm run test:js", 37 | "test:js": "echo No JS tests.", 38 | "lint": "npm run lint:go && npm run lint:js", 39 | "lint:go": "make lint", 40 | "lint:js": "ESLINT_USE_FLAT_CONFIG=false eslint -c .eslintrc.cjs ./", 41 | "prepare": "go mod vendor && rimraf ./.tools", 42 | "precommit": "npm run format" 43 | }, 44 | "prettier": { 45 | "semi": false, 46 | "singleQuote": true 47 | }, 48 | "devDependencies": { 49 | "@aptre/common": "^0.22.1", 50 | "depcheck": "^1.4.7", 51 | "prettier": "^3.2.5", 52 | "rimraf": "^6.0.1", 53 | "typescript": "^5.8.3" 54 | }, 55 | "dependencies": { 56 | "@aptre/protobuf-es-lite": "^0.4.7", 57 | "starpc": "^0.38.1" 58 | }, 59 | "resolutions": { 60 | "@aptre/protobuf-es-lite": "0.4.8" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /padding/padding.go: -------------------------------------------------------------------------------- 1 | package padding 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | ) 6 | 7 | const alignPaddingTo = 32 8 | 9 | // PadInPlace attempts to extend data out to 32 byte intervals. 10 | // Appends a 1-byte trailer with the padding length. 11 | func PadInPlace(data []byte) []byte { 12 | var paddingLen byte 13 | dataLen := len(data) + 1 // for extra padding length byte 14 | if dlm := dataLen % alignPaddingTo; dlm != 0 { 15 | paddingLen = byte(alignPaddingTo - dlm) 16 | } 17 | nlen := dataLen + int(paddingLen) 18 | if cap(data) >= nlen { 19 | oldLen := len(data) 20 | data = data[:nlen] // extend slice with existing capacity 21 | for i := oldLen; i < nlen; i++ { 22 | data[i] = 0 // zero out old region 23 | } 24 | data[len(data)-1] = paddingLen 25 | } else { 26 | og := data 27 | data = make([]byte, nlen) // zeroed by golang 28 | copy(data, og) 29 | data[len(data)-1] = paddingLen 30 | // original buffer is released 31 | } 32 | return data 33 | } 34 | 35 | // UnpadInPlace removes padding according to the appended length byte. 36 | func UnpadInPlace(data []byte) ([]byte, error) { 37 | paddingLen := int(data[len(data)-1]) 38 | if paddingLen >= len(data)-1 || paddingLen >= alignPaddingTo || paddingLen < 0 { 39 | return nil, errors.Errorf( 40 | "%d padding indicated but message is %d bytes", 41 | paddingLen, 42 | len(data), 43 | ) 44 | } 45 | data = data[:len(data)-paddingLen-1] 46 | return data, nil 47 | } 48 | -------------------------------------------------------------------------------- /padding/padding_test.go: -------------------------------------------------------------------------------- 1 | package padding 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "testing" 7 | ) 8 | 9 | func TestPadUnpad(t *testing.T) { 10 | data := make([]byte, 27) 11 | _, err := rand.Read(data) 12 | if err != nil { 13 | t.Fatal(err.Error()) 14 | } 15 | 16 | og := make([]byte, len(data)) 17 | copy(og, data) 18 | padded := PadInPlace(data) 19 | if len(padded) != 32 { 20 | t.Fail() 21 | } 22 | unpadded, err := UnpadInPlace(padded) 23 | if err != nil { 24 | t.Fatal(err.Error()) 25 | } 26 | if !bytes.Equal(unpadded, og) { 27 | t.Fatalf("pad unpad fail: %v != %v", unpadded, padded) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /prng/prng.go: -------------------------------------------------------------------------------- 1 | package prng 2 | 3 | import ( 4 | "crypto/sha256" 5 | "math/rand/v2" 6 | ) 7 | 8 | // BuildSeededRand builds a random source seeded by data. 9 | func BuildSeededRand(datas ...[]byte) rand.Source { 10 | h := sha256.New() 11 | _, _ = h.Write([]byte("prng seed random in BuildSeededRand")) 12 | for _, d := range datas { 13 | _, _ = h.Write(d) 14 | } 15 | sum := h.Sum(nil) 16 | var seed [32]byte 17 | copy(seed[:], sum) 18 | return rand.NewChaCha8(seed) 19 | } 20 | -------------------------------------------------------------------------------- /prng/prng_test.go: -------------------------------------------------------------------------------- 1 | package prng 2 | 3 | import ( 4 | "slices" 5 | "testing" 6 | ) 7 | 8 | // TestBuildSeededRand tests builds a random source seeded by data. 9 | func TestBuildSeededRand(t *testing.T) { 10 | rnd := BuildSeededRand([]byte("testing TestBuildSeededRand")) 11 | 12 | // ensure we have consistent output 13 | expected := []uint64{0x4de92abdfe46af09, 0xb04f7dea4c9c5140, 0x65b78a432144035, 0x51b965a601c7f14c, 0x13c7f4665c1d62ba, 0x1a7e6af46e8e425b, 0xaa95b5b840cba2a0, 0xdd6b1e4ad2892ec7, 0xefbf289c56df8240, 0x416d2dccc09152b, 0x27904db0d3577b93, 0xd724a9b5091763f6, 0x85fb48d7f028ffa5, 0x11718cafa1f0b20f, 0x2ae682d75aed60e, 0x5903f7b00356422d, 0x3757c473e500a6f1, 0x2f52b5bc048442b, 0xbc0e63652c90911b, 0x9e13b433398ae24d, 0x4a97a1d755bb88d6, 0x1d07ef9fdc565b9d} 14 | nums := make([]uint64, len(expected)) 15 | 16 | for i := range nums { 17 | nums[i] = rnd.Uint64() 18 | } 19 | 20 | if !slices.Equal(nums, expected) { 21 | t.Logf("expected: %#v", expected) 22 | t.Logf("actual: %#v", nums) 23 | t.FailNow() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /prng/reader.go: -------------------------------------------------------------------------------- 1 | package prng 2 | 3 | import ( 4 | "io" 5 | "math/rand/v2" 6 | ) 7 | 8 | // randReader wraps a Source to implement io.Reader using random uint64 values. 9 | type randReader struct { 10 | src rand.Source 11 | buf [8]byte 12 | off int 13 | } 14 | 15 | // SourceToReader builds an io.Reader from a rand.Source. 16 | // 17 | // NOTE: the reader is not safe for concurrent use. 18 | func SourceToReader(src rand.Source) io.Reader { 19 | return &randReader{src: src} 20 | } 21 | 22 | // BuildSeededReader builds a random reader seeded by data. 23 | // 24 | // NOTE: the reader is not safe for concurrent use. 25 | func BuildSeededReader(datas ...[]byte) io.Reader { 26 | rd := BuildSeededRand(datas...) 27 | return SourceToReader(rd) 28 | } 29 | 30 | // Read generates random data and writes it into p. 31 | // It reads up to len(p) bytes into p and returns the number of bytes read and any error encountered. 32 | func (r *randReader) Read(p []byte) (n int, err error) { 33 | for n < len(p) { 34 | if r.off == 0 { 35 | // Generate a new random uint64 value and store it in the buffer. 36 | val := r.src.Uint64() 37 | for i := 0; i < 8; i++ { 38 | r.buf[i] = byte(val >> (i * 8)) 39 | } 40 | } 41 | 42 | // Determine how many bytes to copy from the buffer. 43 | remaining := len(p) - n 44 | if remaining > 8-r.off { 45 | remaining = 8 - r.off 46 | } 47 | 48 | // Copy bytes from the buffer into p. 49 | copy(p[n:], r.buf[r.off:r.off+remaining]) 50 | n += remaining 51 | r.off = (r.off + remaining) % 8 52 | } 53 | return n, nil 54 | } 55 | -------------------------------------------------------------------------------- /prng/reader_test.go: -------------------------------------------------------------------------------- 1 | package prng 2 | 3 | import ( 4 | "slices" 5 | "testing" 6 | ) 7 | 8 | func TestSourceToReader(t *testing.T) { 9 | src := BuildSeededRand([]byte("test source to reader")) 10 | reader := SourceToReader(src) 11 | 12 | // Define test cases with different buffer sizes 13 | expected := [][]byte{ 14 | {0xbc, 0xbb, 0x88}, 15 | {0x77, 0x8c, 0x93, 0x40, 0xb5}, 16 | {0xda, 0xe6, 0x94, 0x95, 0xeb, 0xa2, 0x26}, 17 | {0x51, 0xe4}, 18 | {0xe6, 0x73, 0xdd, 0x4, 0x86, 0x83}, 19 | } 20 | 21 | // Perform reads for each test case 22 | for _, tc := range expected { 23 | buf := make([]byte, len(tc)) 24 | 25 | // Read data from the reader into the buffer 26 | n, err := reader.Read(buf) 27 | if err != nil { 28 | t.Fatalf("Read() error = %v, wantErr %v", err, false) 29 | } 30 | if n != len(buf) { 31 | t.Errorf("Read() got = %v, want %v", n, len(buf)) 32 | } 33 | 34 | // Ensure we have consistent output 35 | if !slices.Equal(buf, tc) { 36 | t.Logf("bufSize: %d", len(tc)) 37 | t.Logf("expected: %#v", expected) 38 | t.Logf("actual: %#v", buf) 39 | t.FailNow() 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /promise/container.go: -------------------------------------------------------------------------------- 1 | package promise 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/aperturerobotics/util/broadcast" 7 | ) 8 | 9 | // PromiseContainer contains a Promise which can be replaced with a new Promise. 10 | // 11 | // The zero-value of this struct is valid. 12 | type PromiseContainer[T any] struct { 13 | // bcast is broadcasted when the promise is replaced. 14 | // guards below fields 15 | bcast broadcast.Broadcast 16 | // promise contains the current promise. 17 | promise PromiseLike[T] 18 | } 19 | 20 | // NewPromiseContainer constructs a new PromiseContainer. 21 | func NewPromiseContainer[T any]() *PromiseContainer[T] { 22 | return &PromiseContainer[T]{} 23 | } 24 | 25 | // GetPromise returns the Promise contained in the PromiseContainer and a 26 | // channel that is closed when the Promise is replaced. 27 | // 28 | // Note that promise may be nil. 29 | func (c *PromiseContainer[T]) GetPromise() (prom PromiseLike[T], waitCh <-chan struct{}) { 30 | c.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 31 | prom, waitCh = c.promise, getWaitCh() 32 | }) 33 | return 34 | } 35 | 36 | // SetPromise updates the Promise contained in the PromiseContainer. 37 | // Note: this does not do anything with the old promise. 38 | func (c *PromiseContainer[T]) SetPromise(p PromiseLike[T]) { 39 | c.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 40 | if c.promise != p { 41 | c.promise = p 42 | broadcast() 43 | } 44 | }) 45 | } 46 | 47 | // SetResult sets the result of the promise. 48 | // 49 | // Overwrites the existing promise with a new promise. 50 | func (p *PromiseContainer[T]) SetResult(val T, err error) bool { 51 | prom := NewPromiseWithResult(val, err) 52 | p.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 53 | p.promise = prom 54 | broadcast() 55 | }) 56 | return true 57 | } 58 | 59 | // Await waits for the result to be set or for ctx to be canceled. 60 | func (p *PromiseContainer[T]) Await(ctx context.Context) (val T, err error) { 61 | for { 62 | var waitCh <-chan struct{} 63 | var prom PromiseLike[T] 64 | p.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 65 | prom, waitCh = p.promise, getWaitCh() 66 | }) 67 | if prom == nil { 68 | select { 69 | case <-ctx.Done(): 70 | return val, context.Canceled 71 | case <-waitCh: 72 | continue 73 | } 74 | } 75 | 76 | val, valErr := prom.AwaitWithCancelCh(ctx, waitCh) 77 | if valErr == nil { 78 | return val, nil 79 | } 80 | if valErr == context.Canceled { 81 | if ctx.Err() != nil { 82 | return val, context.Canceled 83 | } 84 | } else { 85 | return val, valErr 86 | } 87 | } 88 | } 89 | 90 | // AwaitWithErrCh waits for the result to be set or for an error to be pushed to the channel. 91 | func (p *PromiseContainer[T]) AwaitWithErrCh(ctx context.Context, errCh <-chan error) (val T, err error) { 92 | for { 93 | var waitCh <-chan struct{} 94 | var prom PromiseLike[T] 95 | p.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 96 | prom, waitCh = p.promise, getWaitCh() 97 | }) 98 | if prom == nil { 99 | select { 100 | case <-ctx.Done(): 101 | return val, context.Canceled 102 | case err, ok := <-errCh: 103 | if !ok { 104 | // errCh was non-nil but was closed 105 | // treat this as context canceled 106 | return val, context.Canceled 107 | } 108 | return val, err 109 | case <-waitCh: 110 | continue 111 | } 112 | } 113 | 114 | val, valErr := prom.AwaitWithCancelCh(ctx, waitCh) 115 | if valErr == nil { 116 | return val, nil 117 | } 118 | if valErr == context.Canceled { 119 | if ctx.Err() != nil { 120 | return val, context.Canceled 121 | } 122 | } else { 123 | return val, valErr 124 | } 125 | } 126 | } 127 | 128 | // AwaitWithCancelCh waits for the result to be set or for the channel to be written to and/or closed. 129 | // 130 | // CancelCh could be a context.Done() channel. 131 | // 132 | // Will return nil, nil if the cancelCh is closed. 133 | // Returns nil, context.Canceled if ctx is canceled. 134 | // Otherwise waits for a value or an error to be set to the promise. 135 | func (p *PromiseContainer[T]) AwaitWithCancelCh(ctx context.Context, cancelCh <-chan struct{}) (val T, err error) { 136 | for { 137 | var waitCh <-chan struct{} 138 | var prom PromiseLike[T] 139 | p.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 140 | prom, waitCh = p.promise, getWaitCh() 141 | }) 142 | if prom == nil { 143 | select { 144 | case <-ctx.Done(): 145 | return val, context.Canceled 146 | case <-cancelCh: 147 | return val, nil 148 | case <-waitCh: 149 | continue 150 | } 151 | } 152 | 153 | val, valErr := prom.AwaitWithCancelCh(ctx, waitCh) 154 | if valErr == nil { 155 | return val, nil 156 | } 157 | if valErr == context.Canceled { 158 | if ctx.Err() != nil { 159 | return val, context.Canceled 160 | } 161 | } else { 162 | return val, valErr 163 | } 164 | } 165 | } 166 | 167 | // _ is a type assertion 168 | var _ PromiseLike[bool] = ((*PromiseContainer[bool])(nil)) 169 | -------------------------------------------------------------------------------- /promise/container_test.go: -------------------------------------------------------------------------------- 1 | package promise 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | // TestPromiseContainer tests the PromiseContainer mechanics. 9 | func TestPromiseContainer(t *testing.T) { 10 | ctx := context.Background() 11 | err := CheckPromiseLike(ctx, func() PromiseLike[int] { 12 | return NewPromise[int]() 13 | }) 14 | if err != nil { 15 | t.Fatal(err.Error()) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /promise/like.go: -------------------------------------------------------------------------------- 1 | package promise 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | // PromiseLike is any object which satisfies the Promise interface. 11 | type PromiseLike[T any] interface { 12 | // SetResult sets the result of the promise. 13 | // 14 | // Returns false if the result was already set. 15 | SetResult(val T, err error) bool 16 | // Await awaits for a result or for ctx to be canceled. 17 | Await(ctx context.Context) (val T, err error) 18 | // AwaitWithErrCh waits for the result to be set or for an error to be pushed to the channel. 19 | AwaitWithErrCh(ctx context.Context, errCh <-chan error) (val T, err error) 20 | // AwaitWithCancelCh waits for the result to be set or for the channel to be written to and/or closed. 21 | // CancelCh could be a context.Done() channel. 22 | AwaitWithCancelCh(ctx context.Context, errCh <-chan struct{}) (val T, err error) 23 | } 24 | 25 | // CheckPromiseLike runs some tests against the PromiseLike. 26 | // 27 | // intended to be used in go tests 28 | func CheckPromiseLike(ctx context.Context, ctor func() PromiseLike[int]) error { 29 | p1 := ctor() 30 | 31 | // test context canceled during await 32 | p1Ctx, p1CtxCancel := context.WithCancel(ctx) 33 | go func() { 34 | <-time.After(time.Millisecond * 50) 35 | p1CtxCancel() 36 | }() 37 | _, err := p1.Await(p1Ctx) 38 | if err != context.Canceled { 39 | return errors.New("expected await to return context canceled") 40 | } 41 | 42 | // test SetResult during Await 43 | go func() { 44 | <-time.After(time.Millisecond * 50) 45 | _ = p1.SetResult(5, nil) 46 | }() 47 | val, err := p1.Await(ctx) 48 | if err != nil { 49 | return err 50 | } 51 | if val != 5 { 52 | return errors.Errorf("expected value 5 but got %v", val) 53 | } 54 | 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /promise/once.go: -------------------------------------------------------------------------------- 1 | package promise 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | ) 7 | 8 | // Once contains a function that is called concurrently once. 9 | // 10 | // The result is returned as a promise. 11 | // If the function returns no error, the result is stored and memoized. 12 | // 13 | // Otherwise, future calls to the function will try again. 14 | type Once[T comparable] struct { 15 | cb func(ctx context.Context) (T, error) 16 | mtx sync.Mutex 17 | prom *Promise[T] 18 | } 19 | 20 | // NewOnce constructs a new Once caller. 21 | func NewOnce[T comparable](cb func(ctx context.Context) (T, error)) *Once[T] { 22 | return &Once[T]{cb: cb} 23 | } 24 | 25 | // Start attempts to start resolution returning the promise. 26 | 27 | // Resolve attempts to resolve the value using the ctx. 28 | func (o *Once[T]) Resolve(ctx context.Context) (T, error) { 29 | for { 30 | var empty T 31 | if err := ctx.Err(); err != nil { 32 | return empty, context.Canceled 33 | } 34 | 35 | o.mtx.Lock() 36 | prom := o.prom 37 | 38 | // start if not running 39 | if prom == nil { 40 | prom = NewPromise[T]() 41 | o.prom = prom 42 | 43 | go func() { 44 | result, err := o.cb(ctx) 45 | if err != nil { 46 | o.mtx.Lock() 47 | if o.prom == prom { 48 | o.prom = nil 49 | } 50 | o.mtx.Unlock() 51 | 52 | if ctx.Err() != nil { 53 | prom.SetResult(empty, context.Canceled) 54 | } else { 55 | prom.SetResult(empty, err) 56 | } 57 | } else { 58 | prom.SetResult(result, err) 59 | } 60 | }() 61 | } 62 | o.mtx.Unlock() 63 | 64 | // await result 65 | res, err := prom.Await(ctx) 66 | if err == context.Canceled { 67 | continue 68 | } 69 | return res, err 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /promise/once_test.go: -------------------------------------------------------------------------------- 1 | package promise 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "sync" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestOnce(t *testing.T) { 12 | t.Run("ResolveOnce", func(t *testing.T) { 13 | callCount := 0 14 | o := NewOnce(func(ctx context.Context) (int, error) { 15 | callCount++ 16 | return 42, nil 17 | }) 18 | 19 | ctx := context.Background() 20 | result, err := o.Resolve(ctx) 21 | if err != nil { 22 | t.Fatalf("Unexpected error: %v", err) 23 | } 24 | if result != 42 { 25 | t.Errorf("Expected 42, got %d", result) 26 | } 27 | if callCount != 1 { 28 | t.Errorf("Expected callback to be called once, got %d", callCount) 29 | } 30 | 31 | // Call again, should return same result without calling the function 32 | result, err = o.Resolve(ctx) 33 | if err != nil { 34 | t.Fatalf("Unexpected error on second call: %v", err) 35 | } 36 | if result != 42 { 37 | t.Errorf("Expected 42 on second call, got %d", result) 38 | } 39 | if callCount != 1 { 40 | t.Errorf("Expected callback to still be called once, got %d", callCount) 41 | } 42 | }) 43 | 44 | t.Run("ResolveError", func(t *testing.T) { 45 | callCount := 0 46 | expectedError := errors.New("test error") 47 | o := NewOnce(func(ctx context.Context) (int, error) { 48 | callCount++ 49 | return 0, expectedError 50 | }) 51 | 52 | ctx := context.Background() 53 | _, err := o.Resolve(ctx) 54 | if err != expectedError { 55 | t.Fatalf("Expected error %v, got %v", expectedError, err) 56 | } 57 | if callCount != 1 { 58 | t.Errorf("Expected callback to be called once, got %d", callCount) 59 | } 60 | 61 | // Call again, should retry 62 | _, err = o.Resolve(ctx) 63 | if err != expectedError { 64 | t.Fatalf("Expected error %v on second call, got %v", expectedError, err) 65 | } 66 | if callCount != 2 { 67 | t.Errorf("Expected callback to be called twice, got %d", callCount) 68 | } 69 | }) 70 | 71 | t.Run("ResolveConcurrent", func(t *testing.T) { 72 | var mu sync.Mutex 73 | callCount := 0 74 | o := NewOnce(func(ctx context.Context) (int, error) { 75 | mu.Lock() 76 | defer mu.Unlock() 77 | callCount++ 78 | time.Sleep(10 * time.Millisecond) // Simulate some work 79 | return 42, nil 80 | }) 81 | 82 | ctx := context.Background() 83 | var wg sync.WaitGroup 84 | for i := 0; i < 10; i++ { 85 | wg.Add(1) 86 | go func() { 87 | defer wg.Done() 88 | result, err := o.Resolve(ctx) 89 | if err != nil { 90 | t.Errorf("Unexpected error: %v", err) 91 | } 92 | if result != 42 { 93 | t.Errorf("Expected 42, got %d", result) 94 | } 95 | }() 96 | } 97 | wg.Wait() 98 | 99 | if callCount != 1 { 100 | t.Errorf("Expected callback to be called once, got %d", callCount) 101 | } 102 | }) 103 | 104 | t.Run("ResolveWithCanceledContext", func(t *testing.T) { 105 | o := NewOnce(func(ctx context.Context) (int, error) { 106 | return 42, nil 107 | }) 108 | 109 | ctx, cancel := context.WithCancel(context.Background()) 110 | cancel() // Cancel the context immediately 111 | 112 | _, err := o.Resolve(ctx) 113 | if err != context.Canceled { 114 | t.Fatalf("Expected context.Canceled error, got %v", err) 115 | } 116 | }) 117 | } 118 | -------------------------------------------------------------------------------- /promise/promise.go: -------------------------------------------------------------------------------- 1 | package promise 2 | 3 | import ( 4 | "context" 5 | "sync/atomic" 6 | ) 7 | 8 | // Promise is an asynchronous result to an operation. 9 | type Promise[T any] struct { 10 | // isDone is an atomic int indicating the promise has been resolved. 11 | isDone atomic.Bool 12 | // done is closed when the promise has been completed. 13 | done chan struct{} 14 | // result is the result of the promise. 15 | result *T 16 | // err is the error result of the promise. 17 | err error 18 | } 19 | 20 | // NewPromise constructs a new empty Promise. 21 | func NewPromise[T any]() *Promise[T] { 22 | return &Promise[T]{done: make(chan struct{})} 23 | } 24 | 25 | // NewPromiseWithResult constructs a promise pre-resolved with a result. 26 | func NewPromiseWithResult[T any](val T, err error) *Promise[T] { 27 | p := &Promise[T]{ 28 | done: make(chan struct{}), 29 | result: &val, 30 | err: err, 31 | } 32 | close(p.done) 33 | p.isDone.Store(true) 34 | return p 35 | } 36 | 37 | // NewPromiseWithErr constructs a promise pre-resolved with an error. 38 | func NewPromiseWithErr[T any](err error) *Promise[T] { 39 | var empty T 40 | return NewPromiseWithResult(empty, err) 41 | } 42 | 43 | // SetResult sets the result of the promise. 44 | // 45 | // Returns false if the result was already set. 46 | func (p *Promise[T]) SetResult(val T, err error) bool { 47 | if p.isDone.Swap(true) { 48 | return false 49 | } 50 | p.result = &val 51 | p.err = err 52 | close(p.done) 53 | return true 54 | } 55 | 56 | // Await waits for the result to be set or for ctx to be canceled. 57 | func (p *Promise[T]) Await(ctx context.Context) (val T, err error) { 58 | select { 59 | case <-ctx.Done(): 60 | return val, context.Canceled 61 | case <-p.done: 62 | return *p.result, p.err 63 | } 64 | } 65 | 66 | // AwaitWithErrCh waits for the result to be set or for an error to be pushed to the channel. 67 | func (p *Promise[T]) AwaitWithErrCh(ctx context.Context, errCh <-chan error) (val T, err error) { 68 | select { 69 | case <-ctx.Done(): 70 | return val, context.Canceled 71 | case err, ok := <-errCh: 72 | if !ok { 73 | // errCh was non-nil but was closed 74 | // treat this as context canceled 75 | return val, context.Canceled 76 | } 77 | return val, err 78 | 79 | case <-p.done: 80 | return *p.result, p.err 81 | } 82 | } 83 | 84 | // AwaitWithCancelCh waits for the result to be set or for the channel to be written to and/or closed. 85 | // Returns nil, context.Canceled if the cancelCh reads. 86 | func (p *Promise[T]) AwaitWithCancelCh(ctx context.Context, cancelCh <-chan struct{}) (val T, err error) { 87 | select { 88 | case <-ctx.Done(): 89 | return val, context.Canceled 90 | case <-cancelCh: 91 | return val, context.Canceled 92 | case <-p.done: 93 | return *p.result, p.err 94 | } 95 | } 96 | 97 | // _ is a type assertion 98 | var _ PromiseLike[bool] = ((*Promise[bool])(nil)) 99 | -------------------------------------------------------------------------------- /promise/promise_test.go: -------------------------------------------------------------------------------- 1 | package promise 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | // TestPromise tests the Promise mechanics. 9 | func TestPromise(t *testing.T) { 10 | ctx := context.Background() 11 | err := CheckPromiseLike(ctx, func() PromiseLike[int] { 12 | return NewPromise[int]() 13 | }) 14 | if err != nil { 15 | t.Fatal(err.Error()) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /result/result.go: -------------------------------------------------------------------------------- 1 | package result 2 | 3 | // Result contains the result tuple of an operation. 4 | type Result[T comparable] struct { 5 | // val is the value 6 | val T 7 | // err is the error 8 | err error 9 | } 10 | 11 | // NewResult constructs a new result container. 12 | func NewResult[T comparable](val T, err error) *Result[T] { 13 | return &Result[T]{val: val, err: err} 14 | } 15 | 16 | // Compare compares two Result objects for equality. 17 | func (r *Result[T]) Compare(ot *Result[T]) bool { 18 | return r.val == ot.val && r.err == ot.err 19 | } 20 | 21 | // GetValue returns the result and error value. 22 | func (r *Result[T]) GetValue() (val T, err error) { 23 | return r.val, r.err 24 | } 25 | -------------------------------------------------------------------------------- /retry/retry.go: -------------------------------------------------------------------------------- 1 | package retry 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | bo "github.com/aperturerobotics/util/backoff" 8 | backoff "github.com/aperturerobotics/util/backoff/cbackoff" 9 | "github.com/sirupsen/logrus" 10 | ) 11 | 12 | // NewBackOff constructs a new backoff with a config. 13 | func NewBackOff(conf *bo.Backoff) backoff.BackOff { 14 | if conf == nil { 15 | conf = &bo.Backoff{} 16 | } 17 | return conf.Construct() 18 | } 19 | 20 | // DefaultBackoff returns the default backoff. 21 | func DefaultBackoff() backoff.BackOff { 22 | return NewBackOff(nil) 23 | } 24 | 25 | // Retry uses a backoff to re-try a process. 26 | // If the process returns nil or context canceled, it exits. 27 | // If bo is nil, a default one is created. 28 | // Success function will reset the backoff. 29 | func Retry( 30 | ctx context.Context, 31 | le *logrus.Entry, 32 | f func(ctx context.Context, success func()) error, 33 | bo backoff.BackOff, 34 | ) error { 35 | if bo == nil { 36 | bo = DefaultBackoff() 37 | } 38 | 39 | for { 40 | le.Debug("starting process") 41 | err := f(ctx, bo.Reset) 42 | select { 43 | case <-ctx.Done(): 44 | return ctx.Err() 45 | default: 46 | } 47 | 48 | if err == nil { 49 | return nil 50 | } 51 | 52 | b := bo.NextBackOff() 53 | le. 54 | WithError(err). 55 | WithField("backoff", b.String()). 56 | Warn("process failed, retrying") 57 | select { 58 | case <-ctx.Done(): 59 | return ctx.Err() 60 | case <-time.After(b): 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /routine/options.go: -------------------------------------------------------------------------------- 1 | package routine 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/aperturerobotics/util/backoff" 7 | cbackoff "github.com/aperturerobotics/util/backoff/cbackoff" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // Option is an option for a RoutineContainer instance. 12 | type Option interface { 13 | // ApplyToRoutineContainer applies the option to the RoutineContainer. 14 | ApplyToRoutineContainer(k *RoutineContainer) 15 | } 16 | 17 | type option struct { 18 | cb func(k *RoutineContainer) 19 | } 20 | 21 | // newOption constructs a new option. 22 | func newOption(cb func(k *RoutineContainer)) *option { 23 | return &option{cb: cb} 24 | } 25 | 26 | // ApplyToRoutineContainer applies the option to the RoutineContainer instance. 27 | func (o *option) ApplyToRoutineContainer(k *RoutineContainer) { 28 | if o.cb != nil { 29 | o.cb(k) 30 | } 31 | } 32 | 33 | // WithExitCb adds a callback after a routine exits. 34 | func WithExitCb(cb func(err error)) Option { 35 | return newOption(func(k *RoutineContainer) { 36 | k.exitedCbs = append(k.exitedCbs, cb) 37 | }) 38 | } 39 | 40 | // WithExitLogger adds a exited callback which logs information about the exit. 41 | func WithExitLogger(le *logrus.Entry) Option { 42 | return WithExitCb(NewLogExitedCallback(le)) 43 | } 44 | 45 | // NewLogExitedCallback returns a ExitedCb which logs when a controller exited. 46 | func NewLogExitedCallback(le *logrus.Entry) func(err error) { 47 | return func(err error) { 48 | if err != nil && err != context.Canceled { 49 | le.WithError(err).Warnf("routine exited") 50 | } else { 51 | le.Debug("routine exited") 52 | } 53 | } 54 | } 55 | 56 | // WithRetry configures a backoff configuration to use when the routine returns an error. 57 | // 58 | // resets the backoff if the routine returned successfully. 59 | // disables the backoff if config is nil 60 | func WithRetry(boConf *backoff.Backoff) Option { 61 | return newOption(func(k *RoutineContainer) { 62 | if boConf == nil { 63 | k.retryBo = nil 64 | return 65 | } 66 | 67 | k.retryBo = boConf.Construct() 68 | }) 69 | } 70 | 71 | // WithBackoff configures a backoff to use when the routine returns an error. 72 | // 73 | // resets the backoff if the routine returned successfully. 74 | // disables the backoff if bo = nil 75 | func WithBackoff(bo cbackoff.BackOff) Option { 76 | return newOption(func(k *RoutineContainer) { 77 | k.retryBo = bo 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /routine/result.go: -------------------------------------------------------------------------------- 1 | package routine 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/aperturerobotics/util/promise" 7 | ) 8 | 9 | // StateResultRoutine is a function called as a goroutine with a state parameter. 10 | // If the state changes, ctx will be canceled and the function restarted. 11 | // If nil is returned as first return value, exits cleanly permanently. 12 | // If an error is returned, can still be restarted later. 13 | // The second return value is the result value. 14 | // This is a wrapper around StateRoutine that also returns a result. 15 | type StateResultRoutine[T comparable, R any] func(ctx context.Context, st T) (R, error) 16 | 17 | // NewStateResultRoutine constructs a new StateRoutine from a StateResultRoutine. 18 | // The routine stores the result in the PromiseContainer. 19 | func NewStateResultRoutine[T comparable, R any](srr StateResultRoutine[T, R]) (StateRoutine[T], *promise.PromiseContainer[R]) { 20 | ctr := promise.NewPromiseContainer[R]() 21 | return NewStateResultRoutineWithPromiseContainer(srr, ctr), ctr 22 | } 23 | 24 | // NewStateResultRoutineWithPromiseContainer constructs a new StateRoutine from a StateResultRoutine. 25 | // The routine stores the result in the provided PromiseContainer. 26 | func NewStateResultRoutineWithPromiseContainer[T comparable, R any]( 27 | srr StateResultRoutine[T, R], 28 | resultCtr *promise.PromiseContainer[R], 29 | ) StateRoutine[T] { 30 | return func(ctx context.Context, st T) error { 31 | prom := promise.NewPromise[R]() 32 | resultCtr.SetPromise(prom) 33 | 34 | result, err := srr(ctx, st) 35 | if ctx.Err() != nil { 36 | return context.Canceled 37 | } 38 | prom.SetResult(result, err) 39 | 40 | return err 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /routine/result_test.go: -------------------------------------------------------------------------------- 1 | package routine 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/aperturerobotics/util/promise" 8 | ) 9 | 10 | // TestStateResultRoutine tests the state result routine functionality 11 | func TestStateResultRoutine(t *testing.T) { 12 | ctx := context.Background() 13 | 14 | // Test successful case 15 | sr, ctr := NewStateResultRoutine(func(ctx context.Context, st int) (string, error) { 16 | return "value:" + string(rune(st+'0')), nil 17 | }) 18 | 19 | // Set initial state and check result 20 | if err := sr(ctx, 1); err != nil { 21 | t.Fatal(err) 22 | } 23 | 24 | prom, _ := ctr.GetPromise() 25 | res, err := prom.Await(ctx) 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | if res != "value:1" { 30 | t.Fatalf("expected value:1 got %v", res) 31 | } 32 | 33 | // Test with custom promise container 34 | customCtr := promise.NewPromiseContainer[string]() 35 | sr3 := NewStateResultRoutineWithPromiseContainer(func(ctx context.Context, st int) (string, error) { 36 | return "custom:" + string(rune(st+'0')), nil 37 | }, customCtr) 38 | 39 | if err := sr3(ctx, 2); err != nil { 40 | t.Fatal(err) 41 | } 42 | 43 | prom3, _ := customCtr.GetPromise() 44 | res, err = prom3.Await(ctx) 45 | if err != nil { 46 | t.Fatal(err) 47 | } 48 | if res != "custom:2" { 49 | t.Fatalf("expected custom:2 got %v", res) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /routine/state_test.go: -------------------------------------------------------------------------------- 1 | package routine 2 | 3 | import ( 4 | "context" 5 | "sync/atomic" 6 | "testing" 7 | "time" 8 | 9 | protobuf_go_lite "github.com/aperturerobotics/protobuf-go-lite" 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | // TestStateRoutineContainer tests the routine container goroutine manager. 14 | func TestStateRoutineContainer(t *testing.T) { 15 | ctx := context.Background() 16 | log := logrus.New() 17 | log.SetLevel(logrus.DebugLevel) 18 | le := logrus.NewEntry(log) 19 | vals := make(chan int) 20 | var exitWithErr atomic.Pointer[error] 21 | var waitReturn chan struct{} 22 | routineFn := func(ctx context.Context, st int) error { 23 | if errPtr := exitWithErr.Load(); errPtr != nil { 24 | return *errPtr 25 | } 26 | if waitReturn != nil { 27 | select { 28 | case <-ctx.Done(): 29 | return context.Canceled 30 | case <-waitReturn: 31 | } 32 | } 33 | select { 34 | case <-ctx.Done(): 35 | return context.Canceled 36 | case vals <- st: 37 | return nil 38 | } 39 | } 40 | 41 | k := NewStateRoutineContainerWithLogger[int](protobuf_go_lite.CompareComparable[int](), le) 42 | if _, wasReset, running := k.SetStateRoutine(routineFn); wasReset || running { 43 | // expected !wasReset and !running before context is set 44 | t.FailNow() 45 | } 46 | 47 | // expect nothing to happen: context is unset. 48 | <-time.After(time.Millisecond * 50) 49 | select { 50 | case val := <-vals: 51 | t.Fatalf("unexpected value before set context: %v", val) 52 | default: 53 | } 54 | 55 | // expect nothing to happen: state is unset 56 | if k.SetContext(ctx, true) { 57 | t.FailNow() 58 | } 59 | 60 | // expect to start now 61 | if _, changed, reset, running := k.SetState(1); !changed || !running || reset { 62 | t.FailNow() 63 | } 64 | 65 | checkVal := func(expected int) { 66 | select { 67 | case nval := <-vals: 68 | if expected != 0 && nval != expected { 69 | t.Fatalf("expected value %v but got %v", nval, expected) 70 | } 71 | default: 72 | t.FailNow() 73 | } 74 | } 75 | 76 | // expect value to be pushed to vals 77 | <-time.After(time.Millisecond * 50) 78 | checkVal(1) 79 | 80 | // expect no extra value after 81 | <-time.After(time.Millisecond * 50) 82 | select { 83 | case <-vals: 84 | t.FailNow() 85 | default: 86 | } 87 | 88 | // restart the routine 89 | if !k.RestartRoutine() { 90 | // expect it to be restarted 91 | t.FailNow() 92 | } 93 | 94 | // expect value to be pushed to vals 95 | <-time.After(time.Millisecond * 50) 96 | checkVal(1) 97 | 98 | // update state 99 | if _, changed, _, running := k.SetState(2); !changed || !running { 100 | t.FailNow() 101 | } 102 | 103 | // expect value to be pushed to vals 104 | <-time.After(time.Millisecond * 50) 105 | checkVal(2) 106 | 107 | // expect nothing happened (no difference) 108 | if _, changed, reset, running := k.SetState(2); changed || reset || running { 109 | t.FailNow() 110 | } 111 | 112 | // unset context 113 | if !k.SetContext(nil, false) { 114 | // expect shutdown 115 | t.FailNow() 116 | } 117 | 118 | // expect nothing happened (no difference) 119 | if k.SetContext(nil, false) { 120 | t.FailNow() 121 | } 122 | 123 | <-time.After(time.Millisecond * 50) 124 | 125 | // test wait exited 126 | var waitExitedReturned atomic.Pointer[error] 127 | waitReturn = make(chan struct{}) 128 | startWaitExited := func() { 129 | go func() { 130 | err := k.WaitExited(ctx, false, nil) 131 | waitExitedReturned.Store(&err) 132 | }() 133 | } 134 | startWaitExited() 135 | 136 | <-time.After(time.Millisecond * 50) 137 | if waitExitedReturned.Load() != nil { 138 | t.FailNow() 139 | } 140 | 141 | // set context 142 | if !k.SetContext(ctx, true) { 143 | t.FailNow() 144 | } 145 | 146 | <-time.After(time.Millisecond * 50) 147 | if waitExitedReturned.Load() != nil { 148 | t.FailNow() 149 | } 150 | 151 | close(waitReturn) 152 | <-time.After(time.Millisecond * 50) 153 | checkVal(2) 154 | <-time.After(time.Millisecond * 50) 155 | if waitExitedReturned.Load() == nil { 156 | t.FailNow() 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /scrub/scrub.go: -------------------------------------------------------------------------------- 1 | package scrub 2 | 3 | // Scrub clears a buffer with zeros. 4 | // Prevents reading sensitive data before memory is overwritten. 5 | func Scrub(buf []byte) { 6 | // compiler optimizes this to memset 7 | for i := 0; i < len(buf); i++ { 8 | buf[i] = 0 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "jsx": "react", 7 | "baseUrl": "./", 8 | "paths": { 9 | "@go/*": ["vendor/*"] 10 | }, 11 | "allowSyntheticDefaultImports": true, 12 | "declaration": true, 13 | "esModuleInterop": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "importsNotUsedAsValues": "remove", 16 | "noEmit": true, 17 | "resolveJsonModule": true, 18 | "skipLibCheck": true, 19 | "strict": true, 20 | "lib": ["webworker", "dom"] 21 | }, 22 | "exclude": ["node_modules", "vendor", "dist"], 23 | "ts-node": { 24 | "esm": true, 25 | "experimentalSpecifierResolution": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /unique/keyedlist.go: -------------------------------------------------------------------------------- 1 | package unique 2 | 3 | import ( 4 | "maps" 5 | "slices" 6 | ) 7 | 8 | // KeyedList watches a list of values for changes. 9 | // 10 | // getKey gets the unique key for the value. 11 | // cmp checks if two values are equal. if equal, the old version of the value is used. 12 | // 13 | // changed is called when a value is added, removed, or changed 14 | // 15 | // K is the key type 16 | // V is the value type 17 | type KeyedList[K, V comparable] struct { 18 | getKey func(val V) K 19 | cmp func(k K, a, b V) bool 20 | changed func(k K, v V, added, removed bool) 21 | vals map[K]V 22 | } 23 | 24 | // NewKeyedList constructs a new KeyedList. 25 | func NewKeyedList[K, V comparable]( 26 | getKey func(v V) K, 27 | cmp func(k K, a, b V) bool, 28 | changed func(k K, v V, added, removed bool), 29 | initial []V, 30 | ) *KeyedList[K, V] { 31 | vals := make(map[K]V, len(initial)) 32 | for _, v := range initial { 33 | k := getKey(v) 34 | vals[k] = v 35 | } 36 | 37 | return &KeyedList[K, V]{ 38 | getKey: getKey, 39 | cmp: cmp, 40 | changed: changed, 41 | vals: vals, 42 | } 43 | } 44 | 45 | // GetKeys returns the list of keys stored in the list. 46 | func (l *KeyedList[K, V]) GetKeys() []K { 47 | return slices.Collect(maps.Keys(l.vals)) 48 | } 49 | 50 | // GetValues returns the list of values stored in the list. 51 | func (l *KeyedList[K, V]) GetValues() []V { 52 | return slices.Collect(maps.Values(l.vals)) 53 | } 54 | 55 | // SetValues sets the list of values contained within the KeyedList. 56 | // 57 | // Values that do not appear in the list are removed. 58 | // Values that are identical to their existing values are ignored. 59 | // Values that change or are added are stored. 60 | func (l *KeyedList[K, V]) SetValues(vals ...V) { 61 | prevKeys := l.GetKeys() 62 | notSeen := make(map[K]struct{}, len(prevKeys)) 63 | for _, prevKey := range prevKeys { 64 | notSeen[prevKey] = struct{}{} 65 | } 66 | 67 | for _, v := range vals { 68 | k := l.getKey(v) 69 | delete(notSeen, k) 70 | existing, ok := l.vals[k] 71 | if ok { 72 | // changed 73 | if !l.cmp(k, v, existing) { 74 | l.vals[k] = v 75 | l.changed(k, v, false, false) 76 | } 77 | } else { 78 | // added 79 | l.vals[k] = v 80 | l.changed(k, v, true, false) 81 | } 82 | } 83 | 84 | // remove not seen vals 85 | for k := range notSeen { 86 | oldVal := l.vals[k] 87 | delete(l.vals, k) 88 | l.changed(k, oldVal, false, true) 89 | } 90 | } 91 | 92 | // AppendValues appends the given values to the list, deduplicating by key. 93 | // 94 | // Values that are identical to their existing values are ignored. 95 | // Values that change or are added are stored. 96 | func (l *KeyedList[K, V]) AppendValues(vals ...V) { 97 | for _, v := range vals { 98 | k := l.getKey(v) 99 | existing, ok := l.vals[k] 100 | if ok { 101 | // changed 102 | if !l.cmp(k, v, existing) { 103 | l.vals[k] = v 104 | l.changed(k, v, false, false) 105 | } 106 | } else { 107 | // added 108 | l.vals[k] = v 109 | l.changed(k, v, true, false) 110 | } 111 | } 112 | } 113 | 114 | // RemoveValues removes the given values from the list by key. 115 | // 116 | // Ignores values that were not in the list. 117 | func (l *KeyedList[K, V]) RemoveValues(vals ...V) { 118 | for _, v := range vals { 119 | k := l.getKey(v) 120 | existing, ok := l.vals[k] 121 | if ok { 122 | // removed 123 | delete(l.vals, k) 124 | l.changed(k, existing, false, true) 125 | } 126 | } 127 | } 128 | 129 | // RemoveKeys removes the given keys from the list. 130 | // 131 | // Ignores values that were not in the list. 132 | func (l *KeyedList[K, V]) RemoveKeys(keys ...K) { 133 | for _, k := range keys { 134 | v, ok := l.vals[k] 135 | if ok { 136 | // removed 137 | delete(l.vals, k) 138 | l.changed(k, v, false, true) 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /unique/keyedlist_test.go: -------------------------------------------------------------------------------- 1 | package unique 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "sort" 7 | "testing" 8 | ) 9 | 10 | type testKeyedListValue struct { 11 | id int 12 | name string 13 | } 14 | 15 | func TestKeyedList(t *testing.T) { 16 | // Setup getKey, cmp, and changed functions 17 | getKey := func(v testKeyedListValue) int { 18 | return v.id 19 | } 20 | 21 | cmp := func(k int, a, b testKeyedListValue) bool { 22 | return a.name == b.name 23 | } 24 | 25 | var changes []struct { 26 | key int 27 | value testKeyedListValue 28 | added bool 29 | removed bool 30 | } 31 | changed := func(k int, v testKeyedListValue, added, removed bool) { 32 | changes = append(changes, struct { 33 | key int 34 | value testKeyedListValue 35 | added bool 36 | removed bool 37 | }{k, v, added, removed}) 38 | } 39 | 40 | initial := []testKeyedListValue{{1, "Alice"}, {2, "Bob"}} 41 | 42 | // Create new KeyedList 43 | list := NewKeyedList[int, testKeyedListValue](getKey, cmp, changed, initial) 44 | 45 | t.Run("GetKeys", func(t *testing.T) { 46 | expectedKeys := []int{1, 2} 47 | keys := list.GetKeys() 48 | sort.Ints(keys) // Ensure the order for comparison 49 | if !reflect.DeepEqual(keys, expectedKeys) { 50 | t.Errorf("expected keys %v, got %v", expectedKeys, keys) 51 | } 52 | }) 53 | 54 | t.Run("GetValues", func(t *testing.T) { 55 | expectedValues := []testKeyedListValue{{1, "Alice"}, {2, "Bob"}} 56 | values := list.GetValues() 57 | sort.Slice(values, func(i, j int) bool { 58 | return values[i].id < values[j].id 59 | }) 60 | if !reflect.DeepEqual(values, expectedValues) { 61 | t.Errorf("expected values %v, got %v", expectedValues, values) 62 | } 63 | }) 64 | 65 | t.Run("SetValues - Add, Update, Remove", func(t *testing.T) { 66 | // Reset changes tracking 67 | changes = nil 68 | 69 | newValues := []testKeyedListValue{{2, "Bobby"}, {3, "Charlie"}} 70 | list.SetValues(newValues...) 71 | 72 | // Prepare a map to track the occurrence of changes 73 | changesMap := make(map[string]struct { 74 | key int 75 | value testKeyedListValue 76 | added bool 77 | removed bool 78 | }) 79 | for _, change := range changes { 80 | key := fmt.Sprintf("%d-%t-%t", change.key, change.added, change.removed) 81 | changesMap[key] = change 82 | } 83 | 84 | // Define a function to check for an expected change 85 | checkForChange := func(expected struct { 86 | key int 87 | value testKeyedListValue 88 | added bool 89 | removed bool 90 | }, 91 | ) { 92 | key := fmt.Sprintf("%d-%t-%t", expected.key, expected.added, expected.removed) 93 | if change, exists := changesMap[key]; !exists || !reflect.DeepEqual(change.value, expected.value) { 94 | t.Errorf("change for key %s not as expected: %+v", key, change) 95 | } 96 | } 97 | 98 | // Check for each expected change 99 | expectedChanges := []struct { 100 | key int 101 | value testKeyedListValue 102 | added bool 103 | removed bool 104 | }{ 105 | {2, newValues[0], false, false}, // updated 106 | {1, initial[0], false, true}, // removed 107 | {3, newValues[1], true, false}, // added 108 | } 109 | for _, expectedChange := range expectedChanges { 110 | checkForChange(expectedChange) 111 | } 112 | }) 113 | 114 | t.Run("AppendValues - Add and Update", func(t *testing.T) { 115 | // Reset changes tracking 116 | changes = nil 117 | 118 | appendValues := []testKeyedListValue{{3, "Charlie"}, {4, "Dana"}} 119 | list.AppendValues(appendValues...) 120 | 121 | // Check for expected changes 122 | expectedChanges := []struct { 123 | key int 124 | value testKeyedListValue 125 | added bool 126 | removed bool 127 | }{ 128 | // Since Charlie is already in the list with the same value, no change should be triggered for it 129 | {4, appendValues[1], true, false}, // added 130 | } 131 | if !reflect.DeepEqual(changes, expectedChanges) { 132 | t.Errorf("expected changes %v, got %v", expectedChanges, changes) 133 | } 134 | }) 135 | 136 | t.Run("RemoveValues and RemoveKeys", func(t *testing.T) { 137 | // Reset changes tracking 138 | changes = nil 139 | 140 | // Remove by value and key 141 | list.RemoveValues(testKeyedListValue{2, "Bobby"}) 142 | list.RemoveKeys(4) // Dana 143 | 144 | expectedChanges := []struct { 145 | key int 146 | value testKeyedListValue 147 | added bool 148 | removed bool 149 | }{ 150 | {2, testKeyedListValue{2, "Bobby"}, false, true}, // removed by value 151 | {4, testKeyedListValue{4, "Dana"}, false, true}, // removed by key 152 | } 153 | if !reflect.DeepEqual(changes, expectedChanges) { 154 | t.Errorf("expected changes %v, got %v", expectedChanges, changes) 155 | } 156 | }) 157 | } 158 | -------------------------------------------------------------------------------- /unique/keyedmap.go: -------------------------------------------------------------------------------- 1 | package unique 2 | 3 | import ( 4 | "maps" 5 | "slices" 6 | ) 7 | 8 | // KeyedMap watches a map of values for changes. 9 | // 10 | // cmp checks if two values are equal. if equal, the old version of the value is used. 11 | // 12 | // changed is called when a value is added, removed, or changed 13 | // 14 | // K is the key type 15 | // V is the value type 16 | type KeyedMap[K, V comparable] struct { 17 | cmp func(k K, a, b V) bool 18 | changed func(k K, v V, added, removed bool) 19 | vals map[K]V 20 | } 21 | 22 | // NewKeyedMap constructs a new KeyedMap. 23 | func NewKeyedMap[K, V comparable]( 24 | cmp func(k K, a, b V) bool, 25 | changed func(k K, v V, added, removed bool), 26 | initial map[K]V, 27 | ) *KeyedMap[K, V] { 28 | vals := make(map[K]V, len(initial)) 29 | for k, v := range initial { 30 | vals[k] = v 31 | } 32 | 33 | return &KeyedMap[K, V]{ 34 | cmp: cmp, 35 | changed: changed, 36 | vals: vals, 37 | } 38 | } 39 | 40 | // GetKeys returns the list of keys stored in the map. 41 | func (l *KeyedMap[K, V]) GetKeys() []K { 42 | return slices.Collect(maps.Keys(l.vals)) 43 | } 44 | 45 | // GetValues returns the list of values stored in the map. 46 | func (l *KeyedMap[K, V]) GetValues() []V { 47 | return slices.Collect(maps.Values(l.vals)) 48 | } 49 | 50 | // SetValues sets the list of values contained within the KeyedMap. 51 | // 52 | // Values that do not appear in the list are removed. 53 | // Values that are identical to their existing values are ignored. 54 | // Values that change or are added are stored. 55 | func (l *KeyedMap[K, V]) SetValues(vals map[K]V) { 56 | prevKeys := l.GetKeys() 57 | notSeen := make(map[K]struct{}, len(prevKeys)) 58 | for _, prevKey := range prevKeys { 59 | notSeen[prevKey] = struct{}{} 60 | } 61 | 62 | for k, v := range vals { 63 | delete(notSeen, k) 64 | existing, ok := l.vals[k] 65 | if ok { 66 | // changed 67 | if !l.cmp(k, v, existing) { 68 | l.vals[k] = v 69 | l.changed(k, v, false, false) 70 | } 71 | } else { 72 | // added 73 | l.vals[k] = v 74 | l.changed(k, v, true, false) 75 | } 76 | } 77 | 78 | // remove not seen vals 79 | for k := range notSeen { 80 | oldVal := l.vals[k] 81 | delete(l.vals, k) 82 | l.changed(k, oldVal, false, true) 83 | } 84 | } 85 | 86 | // AppendValues appends the given values to the list, deduplicating by key. 87 | // 88 | // Values that are identical to their existing values are ignored. 89 | // Values that change or are added are stored. 90 | func (l *KeyedMap[K, V]) AppendValues(vals map[K]V) { 91 | for k, v := range vals { 92 | existing, ok := l.vals[k] 93 | if ok { 94 | // changed 95 | if !l.cmp(k, v, existing) { 96 | l.vals[k] = v 97 | l.changed(k, v, false, false) 98 | } 99 | } else { 100 | // added 101 | l.vals[k] = v 102 | l.changed(k, v, true, false) 103 | } 104 | } 105 | } 106 | 107 | // RemoveKeys removes the given keys from the list. 108 | // 109 | // Ignores values that were not in the list. 110 | func (l *KeyedMap[K, V]) RemoveKeys(keys ...K) { 111 | for _, k := range keys { 112 | v, ok := l.vals[k] 113 | if ok { 114 | // removed 115 | delete(l.vals, k) 116 | l.changed(k, v, false, true) 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /unique/keyedmap_test.go: -------------------------------------------------------------------------------- 1 | package unique 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | type testValue struct { 10 | ID int 11 | Name string 12 | } 13 | 14 | func TestKeyedMap(t *testing.T) { 15 | var changes []struct { 16 | key int 17 | value testValue 18 | added bool 19 | removed bool 20 | } 21 | 22 | // Define a comparison function 23 | cmp := func(k int, a, b testValue) bool { 24 | return a.ID == b.ID && a.Name == b.Name 25 | } 26 | 27 | // Define a change tracking function 28 | changed := func(k int, v testValue, added, removed bool) { 29 | changes = append(changes, struct { 30 | key int 31 | value testValue 32 | added bool 33 | removed bool 34 | }{k, v, added, removed}) 35 | } 36 | 37 | initial := map[int]testValue{ 38 | 1: {1, "Alice"}, 39 | 2: {2, "Bob"}, 40 | } 41 | 42 | kMap := NewKeyedMap(cmp, changed, initial) 43 | 44 | // Test adding, updating, and removing values 45 | t.Run("SetValues - Add, Update, Remove", func(t *testing.T) { 46 | changes = nil // Reset changes tracking 47 | 48 | newValues := map[int]testValue{ 49 | 2: {2, "Bobby"}, 50 | 3: {3, "Charlie"}, 51 | } 52 | kMap.SetValues(newValues) 53 | 54 | expectedChangesMap := map[string]struct { 55 | value testValue 56 | added bool 57 | removed bool 58 | }{ 59 | "2-false-false": {newValues[2], false, false}, // updated 60 | "1-false-true": {initial[1], false, true}, // removed 61 | "3-true-false": {newValues[3], true, false}, // added 62 | } 63 | 64 | for _, change := range changes { 65 | key := formatChangeKey(change.key, change.added, change.removed) 66 | expected, ok := expectedChangesMap[key] 67 | if !ok || !reflect.DeepEqual(change.value, expected.value) { 68 | t.Errorf("Unexpected or incorrect change for key %d: got %+v, want %+v", change.key, change, expected) 69 | } 70 | } 71 | }) 72 | } 73 | 74 | func formatChangeKey(key int, added, removed bool) string { 75 | return fmt.Sprintf("%d-%t-%t", key, added, removed) 76 | } 77 | -------------------------------------------------------------------------------- /vmime/vmime.go: -------------------------------------------------------------------------------- 1 | package vmime 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | ) 7 | 8 | // MimeTypeRe is used to check mime types. 9 | var MimeTypeRe = regexp.MustCompile(`^[-\w.]+/[-\w.]+$`) 10 | 11 | // IsValidMimeType checks if a string is a valid mime type. 12 | func IsValidMimeType(str string) bool { 13 | return MimeTypeRe.MatchString(str) 14 | } 15 | 16 | // ErrInvalidMimeType is returned if the mime type is invalid. 17 | var ErrInvalidMimeType = errors.New("invalid mime type") 18 | --------------------------------------------------------------------------------