diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddc87a1..6448219 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - version="$(sed -n "s/^VERSION='\(.*\)'$/\1/p" bin/git-locks)" + version="$(sed -n "s/^VERSION='\(.*\)'$/\1/p" bin/git-locks)" # the built script; lib/000-prelude.sh is the source [ -n "$version" ] || { echo "no VERSION in bin/git-locks"; exit 1; } tag="v${version}" if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null 2>&1 || gh release view "$tag" >/dev/null 2>&1; then diff --git a/CHANGELOG.md b/CHANGELOG.md index ea4a625..3c3201b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to this project are recorded here. The format follows Keep a ## [Unreleased] +## [0.4.0] - 2026-09-16 + +### Changed + +- The script is built. `bin/git-locks` is assembled by `scripts/build.sh` from `lib/*.sh` in numeric order, with the schema module generated from `schema/git-locks.schema.json`; `make build` writes it, and the test suite refuses a committed `bin/git-locks` that is not byte-for-byte what `lib/` builds (#11). The installed artifact, the release asset and `make install` are unchanged: one file. +- `list` renders without forking. Record fields, paths, the clock and JSON arrays have `printf -v` forms (`field_v`, `record_paths_v`, `now_v`, `json_paths_v`) and the render path uses only those, so each record is parsed once and a list of n locks is O(n) bash with no processes per line; `GIT_LOCKS_TRACE` writes one `parse ` line per record and the suite counts them (#24). +- The snapshot reads `cat-file --batch` output with `read -N` instead of slicing the captured text, which was quadratic in the store size. Measured on 500 locks (macOS, bash 5.3, same store, before and after): `list` 6.75 s to 0.58 s; `check` 0.87 s to 0.28 s; `show` 0.85 s to 0.25 s; `claim` about 1.0 s to 0.33 s. The 0.07 s figures the README carried for 0.3.x were not reproducible on that store and are withdrawn. +- One clock reading per invocation (`now_v` caches it), so every `remaining` in one `list` is computed against the same instant. + +### Fixed + +- A `--ttl` with a leading zero was octal in arithmetic (`010` gave eight seconds; `08` failed); ttl values are decimal everywhere (`claim`, `batch`, `extend`, `sem acquire`, `with`). +- Parsed record fields are stored whole, keyed by record and field name, so no byte in a holder can read as a field delimiter (the first cut of 0.4.0 joined them with control bytes). A holder is one line; `sem acquire` and `with` now refuse a newline in it as `claim` already did. +- A `batch` record with only `parent:` or `ttl:` was skipped as empty and its parent leaked into the next record; it is malformed now. +- `sweep` deletes only the record it saw expire: a lock renewed between its read and its transaction is left alone. +- `with --sem` validates its arguments before acquiring anything, and arms its release traps before the first acquisition, so a signal during the wait for the path lock gives back the slot already taken. +- `check` reads the clock in the parent shell, so `remaining` and `state` on one line agree. +- `version` refuses extra arguments like every other command. + ## [0.3.2] - 2026-09-16 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eacb814..f3289c4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -- Tests are the spec. Write the failing case in `test/test.sh` first, show it red, then change `bin/git-locks`. +- Tests are the spec. Write the failing case in `test/test.sh` first, show it red, then change the module under `lib/` and run `make build`; `bin/git-locks` is the build product and is committed beside the `lib/` change (the suite refuses a stale one). - `make lint` must pass with zero output: shellcheck with every optional check enabled, and shfmt with the repository's settings (`-i 2 -ci -bn`). Do not add a `# shellcheck disable` without a comment saying why. - Pure bash and git only. No jq, no Python, no external daemons. Anything that would need one belongs in a different project. - Keep `README.md` and `CHANGELOG.md` current in the same commit as the change they describe. @@ -12,3 +12,5 @@ - Load the store snapshot once in the parent shell before dispatch; a subshell inherits it, a subshell cannot refresh it for the parent. Invalidate explicitly after any `$(transact …)`. - Never install this tool as a symlink into a checkout you edit. `make install` copies for that reason: a half-fixed branch went live under another project's pre-commit hook on 2026-09-15. - Every git spawn is a test: `test/test.sh` counts them with a shim. Keep one process per protocol per command. + - A fork per record is a fork per record. `$(field …)` inside a render loop costs a process each time and forgets the parse; the `_v` helpers (`field_v`, `record_paths_v`, `now_v`, `json_paths_v`) exist so hot paths never fork. `list` on 500 locks went from 6.75 s to 0.58 s by using them. + - `${text:pos:len}` on a large string copies from `pos` every call; a loop over it is quadratic. Read structured output with `read -N` instead (the snapshot's `cat-file --batch` parse). diff --git a/Makefile b/Makefile index c9a1cdd..fede374 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,12 @@ SHELL := /usr/bin/env bash -SCRIPTS := bin/git-locks test/test.sh scripts/hooks/pre-commit scripts/hooks/pre-push +# lib/*.sh are fragments of one script and only lint as the whole they build into (bin/git-locks). +SCRIPTS := bin/git-locks test/test.sh scripts/hooks/pre-commit scripts/hooks/pre-push scripts/build.sh PREFIX ?= $(HOME)/.local -.PHONY: lint test test-docker install uninstall +.PHONY: build lint test test-docker install uninstall + +build: # assemble bin/git-locks from lib/*.sh and schema/git-locks.schema.json; commit the result with the lib change + bash scripts/build.sh lint: shellcheck -S style -o all $(SCRIPTS) diff --git a/README.md b/README.md index a12ea79..bdbee79 100644 --- a/README.md +++ b/README.md @@ -410,13 +410,13 @@ Output is JSON Lines on every command; there is no text mode. Every JSON line git-locks writes, on stdout or stderr, matches exactly one definition in [`schema/git-locks.schema.json`](schema/git-locks.schema.json) (JSON Schema 2020-12). `git locks schema` prints that document byte-for-byte, and the test suite validates every line it provokes against it, so the contract cannot drift from the code. Consumers can pin the `$id` URL or the file at a tagged commit. -Paths are repo-relative, `./` prefixes are stripped, and absolute or `..` paths are refused. A path may contain spaces; it may not contain a newline. Job ids match `[A-Za-z0-9][A-Za-z0-9._-]*`. +Paths are repo-relative, `./` prefixes are stripped, and absolute or `..` paths are refused. A path may contain spaces; it may not contain a newline. Job ids match `[A-Za-z0-9][A-Za-z0-9._-]*`. A holder is one line of text; any byte but a newline is stored whole and escaped on output. A ttl is a decimal number of seconds; a leading zero is not octal. `GIT_LOCKS_NOW=` fixes the clock, for tests; `GIT_LOCKS_PAUSE_BEFORE_COMMIT=` makes every transaction wait for that file, so tests can force interleavings. Timestamps are epoch seconds. ## Versioning and releases -`VERSION` in `bin/git-locks` is the version. A push to `main` whose version has no tag yet gets an annotated tag `v` and a GitHub release whose notes are that version's section of `CHANGELOG.md`, with the script and the schema attached, from the `release` job in `.github/workflows/ci.yml`. So a release is: bump `VERSION`, write the changelog section, merge. +`VERSION` in `lib/000-prelude.sh` is the version (it lands in `bin/git-locks` at build time). A push to `main` whose version has no tag yet gets an annotated tag `v` and a GitHub release whose notes are that version's section of `CHANGELOG.md`, with the script and the schema attached, from the `release` job in `.github/workflows/ci.yml`. So a release is: bump `VERSION`, `make build`, write the changelog section, merge. ## Install @@ -430,18 +430,21 @@ git locks list # git dispatches `git locks` to git-locks on PATH ## Develop ```sh +make build # assemble bin/git-locks from lib/*.sh and schema/git-locks.schema.json make lint # shellcheck with every optional check on, shfmt make test # test/test.sh, pure bash, temporary repositories; needs python3 with jsonschema for the schema checks git config --local core.hooksPath scripts/hooks # pre-commit lints, pre-push tests ``` +The source is `lib/`, one module per section in numeric order (`000-prelude.sh` through `990-main.sh`); `bin/git-locks` is the build product and is committed, because it is what `make install`, the release asset and a `curl` of the raw file all want: one file, no runtime assembly. Edit under `lib/`, run `make build`, commit both. The suite checks that the committed script is exactly what `lib/` builds, so a `lib/` change without a rebuild fails the pre-push hook and CI. The schema module is generated at build time from `schema/git-locks.schema.json`, so there is one copy of the schema in the repository. Lint runs over the built script rather than the fragments, which do not parse on their own. + ## Limits, stated - The lock is advisory and time-bounded. Nothing stops a writer that never claimed, and nothing renews a reservation under a long command. The consumer that lands writes (a commit script, a CI step) is where refusal belongs; `check` exits 1 for exactly that use, and a `check` is an observation, not an admission. - One machine. The store is local; a shared remote would need a fetch before every claim and is out of scope. - `git rev-parse --path-format=absolute` and `update-ref --stdin` transactions need git 2.31 or newer. - bash 4 or newer: the store snapshot uses associative arrays. macOS's `/bin/bash` is 3.2; the script's shebang finds a newer bash on `PATH` (Homebrew's, for instance). -- Each command reads the store once (`for-each-ref` plus one `cat-file --batch`) and every transaction invalidates that snapshot, so an invocation is a handful of git processes however many locks exist; the test suite pins the counts with a shim that counts spawns. Process count is not time: the snapshot is parsed in bash, so work grows with the store. Measured on 500 locks (macOS, bash 5.3): `check`, `claim` and `show` each about 0.07 s; `list`, which renders every record, 5.8 s. A store of hundreds of live locks is fine; one of thousands wants #11's split and a leaner `list`. +- Each command reads the store once (`for-each-ref` plus one `cat-file --batch`) and every transaction invalidates that snapshot, so an invocation is a handful of git processes however many locks exist; the test suite pins the counts with a shim that counts spawns. Process count is not time: the snapshot is parsed in bash, so work grows linearly with the store, and `list` renders every record without forking. Measured on 500 locks (macOS, bash 5.3, 0.4.0): `check` 0.28 s, `show` 0.25 s, `claim` 0.33 s, `list` 0.58 s; the same store under 0.3.2 took 0.87 s, 0.85 s, 1.0 s and 6.75 s. A store of hundreds of live locks is fine; one of many thousands will feel the snapshot. - Every command reads the store once, plans, then commits with expectations. A racer can win in between; the transaction then fails and the command re-plans or reports who won. That is the designed outcome, not a gap. - The tests are bounded conformance evidence. Twenty racers and one forced interleaving are what the suite shows; they are not a proof over every schedule. diff --git a/bin/git-locks b/bin/git-locks index cb331fb..cadff11 100755 --- a/bin/git-locks +++ b/bin/git-locks @@ -60,8 +60,9 @@ DEFAULT_TTL=14400 SCHEMA='git-locks/1' SEM_SCHEMA='git-locks-sem/1' SLOT_SCHEMA='git-locks-slot/1' -VERSION='0.3.2' -RETRIES=200 # a plan refused for a stale expectation is re-read and re-planned this many times +VERSION='0.4.0' +RETRIES=200 # a plan refused for a stale expectation is re-read and re-planned this many times +NOW_CACHED='' # the clock, read once per invocation by now() usage_text() { cat <<'EOF' @@ -153,7 +154,6 @@ sub_usage_text() { *) usage_text ;; esac } - # ---------------------------------------------------------------- JSON json_str() { # VAR VALUE: set VAR to VALUE as a JSON string, every control character escaped @@ -192,6 +192,20 @@ json_paths() { # VAR: set VAR to a JSON array of the lines on stdin printf -v "$1" '[%s]' "${items[*]}" } +json_paths_v() { # VAR TEXT: set VAR to a JSON array of TEXT's non-empty lines; no fork + local text="$2" line items=() one IFS + while [[ -n "${text}" ]]; do + line="${text%%$'\n'*}" + if [[ "${line}" == "${text}" ]]; then text=''; else text="${text#*$'\n'}"; fi + if [[ -n "${line}" ]]; then + json_str one "${line}" + items+=("${one}") + fi + done + IFS=',' + printf -v "$1" '[%s]' "${items[*]}" +} + json_jobs() { # VAR job... -> JSON array of job ids local var="$1" one items=() IFS j shift @@ -205,7 +219,7 @@ json_jobs() { # VAR job... -> JSON array of job ids parent_json() { # VAR oid -> ',"parent":""' or '' when the record has no parent local p one - p="$(field "$2" parent)" + field_v p "$2" parent if [[ -n "${p}" ]]; then json_str one "${p}" printf -v "$1" ',"parent":%s' "${one}" @@ -213,7 +227,6 @@ parent_json() { # VAR oid -> ',"parent":""' or '' when the record has no par printf -v "$1" '' fi } - # ---------------------------------------------------------------- errors fail() { # message [code]: an error line on stderr, then exit (2 is usage, 1 is a failed operation) @@ -230,15 +243,23 @@ store_error() { # detail: the store could not be read; nothing is reported as fr printf '{"event":"error","reason":"store-read","detail":%s}\n' "${_j1}" >&2 exit 2 } - # ---------------------------------------------------------------- time, refs, records -now() { - if [[ -n "${GIT_LOCKS_NOW:-}" ]]; then - printf '%s' "${GIT_LOCKS_NOW}" - else - date +%s +now_v() { # VAR: set VAR to the clock, read once per invocation; no fork after the first call + if [[ -z "${NOW_CACHED}" ]]; then + if [[ -n "${GIT_LOCKS_NOW:-}" ]]; then + NOW_CACHED="${GIT_LOCKS_NOW}" + else + NOW_CACHED="$(date +%s)" + fi fi + printf -v "$1" '%s' "${NOW_CACHED}" +} + +now() { + local v + now_v v + printf '%s' "${v}" } declare -A PATH_HASH=() # path -> git's hash of the path string, memoised per invocation @@ -256,6 +277,15 @@ job_ref() { printf '%s/jobs/%s' "${NS}" "$1"; } valid_job() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; } +valid_holder() { [[ -n "$1" && "$1" != *$'\n'* && "$1" != *$'\r'* ]]; } # one line: the record is line-oriented; any other byte is stored whole and escaped on output + +valid_ttl() { # VAR value: VAR = the value as a decimal number of seconds; 1 unless it is digits only and positive (010 is ten, never octal eight) + [[ "$2" =~ ^[0-9]+$ ]] || return 1 + local _vt=$((10#$2)) + ((_vt > 0)) || return 1 + printf -v "$1" '%s' "${_vt}" +} + valid_oid() { [[ "$1" =~ ^[0-9a-f]{40}([0-9a-f]{24})?$ ]]; } path_error() { # detail -> a usage error line on stderr (the caller returns 2) @@ -291,7 +321,6 @@ normalize_path() { # -> prints the lexical form, or returns 2 with the reason on } printf '%s' "${parts[*]}" } - # ---------------------------------------------------------------- the store STORE='' @@ -320,7 +349,6 @@ resolve_store() { # sets STORE; creates the default or a custom store on first u } g() { git --git-dir="${STORE}" "$@"; } - # ---------------------------------------------------------------- the snapshot # # One for-each-ref and one cat-file --batch per invocation; every reader below @@ -330,10 +358,34 @@ g() { git --git-dir="${STORE}" "$@"; } # cached read taken under one for-each-ref, not a proof of a consistent cut; # every write below carries the expectations that make a stale read fail. -declare -A REF_OID=() # ref -> oid -declare -A BLOB=() # oid -> record text +declare -A REF_OID=() # ref -> oid +declare -A BLOB=() # oid -> record text +declare -A R_PARSED=() # oid -> 1 once parsed +declare -A R_FIELD=() # "oid key" -> value, for the header lines before paths: (first occurrence wins); values are stored whole, so no byte in one can read as a delimiter +declare -A R_PATHS=() # oid -> the path lines, newline separated SNAP_LOADED=0 +parse_record() { # oid -> R_FIELD["oid key"] and R_PATHS[oid] from BLOB[oid], once per shell; parameter expansion only, no fork + [[ -n "${R_PARSED[$1]+x}" ]] && return 0 + local text="${BLOB[$1]:-}" line key paths='' in_paths=0 + while [[ -n "${text}" ]]; do + line="${text%%$'\n'*}" + if [[ "${line}" == "${text}" ]]; then text=''; else text="${text#*$'\n'}"; fi + if ((in_paths)); then + paths+="${line}"$'\n' + elif [[ "${line}" == 'paths:' ]]; then + in_paths=1 + elif [[ "${line}" == *': '* ]]; then + key="${line%%: *}" + [[ -n "${R_FIELD["$1 ${key}"]+x}" ]] || R_FIELD["$1 ${key}"]="${line#*: }" + fi + done + R_PARSED["$1"]=1 + R_PATHS["$1"]="${paths%$'\n'}" + [[ -n "${GIT_LOCKS_TRACE:-}" ]] && printf 'parse %s\n' "$1" >>"${GIT_LOCKS_TRACE}" + return 0 +} + snapshot() { local -A refs=() blobs=() local rows ref oid oids=() rc @@ -352,23 +404,27 @@ snapshot() { rc=$? ((rc == 0)) || store_error "cat-file --batch exited ${rc}: ${out%x}" out="${out%x}" - local pos=0 len="${#out}" header size - while ((pos < len)); do - header="${out:pos}" - header="${header%%$'\n'*}" - pos=$((pos + ${#header} + 1)) + local header size body nl + # read -N over the captured text is linear; slicing ${out:pos:size} copies from pos every time and is quadratic in the store + while IFS= read -r header; do [[ "${header}" =~ ^([0-9a-f]+)\ blob\ ([0-9]+)$ ]] || store_error "cat-file --batch header does not parse: ${header}" oid="${BASH_REMATCH[1]}" size="${BASH_REMATCH[2]}" - blobs["${oid}"]="${out:pos:size}" - pos=$((pos + size + 1)) - done + body='' + ((size > 0)) && { IFS= read -r -N "${size}" body || store_error "cat-file --batch object ${oid} is short"; } + IFS= read -r -N 1 nl || nl='' + [[ "${nl}" == $'\n' ]] || store_error "cat-file --batch object ${oid} is not newline terminated" + blobs["${oid}"]="${body}" + done <<<"${out%$'\n'}" for oid in "${oids[@]}"; do [[ -n "${blobs[${oid}]+x}" ]] || store_error "object ${oid} named by a ref is missing from the store" done fi REF_OID=() BLOB=() + R_PARSED=() + R_FIELD=() + R_PATHS=() for ref in "${!refs[@]}"; do REF_OID["${ref}"]="${refs[${ref}]}"; done for oid in "${!blobs[@]}"; do BLOB["${oid}"]="${blobs[${oid}]}"; done SNAP_LOADED=1 @@ -393,28 +449,29 @@ ref_oid() { # ref -> oid or empty printf '%s' "${REF_OID[$1]:-}" } -field() { # oid key -> the value of `key:` in the record's header (before paths:) +field_v() { # VAR oid key: set VAR to the value of `key:` in the record's header (before paths:), empty when absent; no fork, so the parse memoises in this shell ensure_snapshot - local line - while IFS= read -r line; do - [[ "${line}" == 'paths:' ]] && return 0 - if [[ "${line}" == "$2: "* ]]; then - printf '%s' "${line#"$2": }" - return 0 - fi - done <<<"${BLOB[$1]:-}" + parse_record "$2" + printf -v "$1" '%s' "${R_FIELD["$2 $3"]:-}" } -record_paths() { # oid -> paths, one per line +field() { # oid key -> the value on stdout; inside $(…) the parse happens in the subshell, so hot paths use field_v + local v + field_v v "$1" "$2" + printf '%s' "${v}" +} + +record_paths_v() { # VAR oid: set VAR to the paths, newline separated; no fork ensure_snapshot - local line in_paths=0 - while IFS= read -r line; do - if ((in_paths)); then - printf '%s\n' "${line}" - elif [[ "${line}" == 'paths:' ]]; then - in_paths=1 - fi - done <<<"${BLOB[$1]:-}" + parse_record "$2" + printf -v "$1" '%s' "${R_PATHS[$2]:-}" +} + +record_paths() { # oid -> paths, one per line + local v + record_paths_v v "$1" + [[ -n "${v}" ]] && printf '%s\n' "${v}" + return 0 } refs_under() { # prefix -> "ref oid" lines, sorted by ref, from the snapshot @@ -427,13 +484,19 @@ refs_under() { # prefix -> "ref oid" lines, sorted by ref, from the snapshot job_refs() { refs_under "${NS}/jobs/"; } -describe() { # oid -> D_HOLDER D_JOB D_EXPIRES D_REMAINING D_STATE - D_HOLDER="$(field "$1" holder)" - D_JOB="$(field "$1" job)" +D_HOLDER='' +D_JOB='' +D_EXPIRES=0 +D_REMAINING=0 +D_STATE='' + +describe() { # oid -> D_HOLDER D_JOB D_EXPIRES D_REMAINING D_STATE; no fork + field_v D_HOLDER "$1" holder + field_v D_JOB "$1" job local exp at - exp="$(field "$1" expires)" + field_v exp "$1" expires D_EXPIRES="${exp:-0}" - at="$(now)" + now_v at D_REMAINING=$((D_EXPIRES - at)) ((D_REMAINING < 0)) && D_REMAINING=0 if ((D_EXPIRES > at)); then D_STATE='live'; else D_STATE='expired'; fi @@ -453,7 +516,6 @@ write_blob() { # VAR CONTENT: write CONTENT as a blob, seed the snapshot with i BLOB["${written}"]="$2"$'\n' printf -v "$1" '%s' "${written}" } - # ---------------------------------------------------------------- the transition plan # # One final transition per ref. Every writer says what it expects a ref to hold @@ -530,7 +592,6 @@ transact() { # commits the plan; 0 ok, 1 refused (TRANSACT_ERR carries git's wor SNAP_LOADED=0 ((rc == 0)) } - # ---------------------------------------------------------------- refusals refusal() { # path, after describe(): one refusal line on stderr @@ -559,7 +620,6 @@ transaction_refusal() { # git's words, as one line json_str _j1 "${TRANSACT_ERR}" printf '{"event":"refused","reason":"transaction","detail":%s}\n' "${_j1}" >&2 } - # ---------------------------------------------------------------- families # # A child records `parent: `. The parent's record carries `family: `, @@ -577,8 +637,8 @@ descendants() { # job... -> DESC: every job whose parent chain reaches one of local all_jobs=() all_parents=() while IFS=' ' read -r ref oid; do [[ -z "${ref}" ]] && continue - rjob="$(field "${oid}" job)" - rparent="$(field "${oid}" parent)" + field_v rjob "${oid}" job + field_v rparent "${oid}" parent all_jobs+=("${rjob}") all_parents+=("${rparent}") done <<<"${rows}" @@ -641,7 +701,7 @@ plan_terminate() { # job -> plans the deletion of the job and every descendant; new_acquisition() { # VAR: a fresh acquisition id. The record oid changes on every rewrite (renewal, family bump); local at # this id does not, so a caller can name the acquisition it made across renewals. - at="$(now)" + now_v at printf -v "$1" '%s-%05d-%05d%05d' "${at}" "$$" "${RANDOM}" "${RANDOM}" } @@ -679,7 +739,6 @@ bump_parent() { # parent-job parent-oid -> plans the parent's blob rewrite wit done <<<"${paths}" return 0 } - # ---------------------------------------------------------------- claim planning BATCH_JOBS=() @@ -703,7 +762,7 @@ plan_claim() { # job holder ttl parent path... -> plans one claim; sets CLAIM while IFS= read -r n; do [[ -n "${n}" ]] && wanted+=("${n}"); done <<<"${sorted}" local at expires - at="$(now)" + now_v at expires=$((at + ttl)) # The parent, if any: live and the same holder, whether it exists already or is planned earlier in this batch. @@ -770,8 +829,8 @@ plan_claim() { # job holder ttl parent path... -> plans one claim; sets CLAIM } continue fi - rjob="$(field "${cur}" job)" - rexp="$(field "${cur}" expires)" + field_v rjob "${cur}" job + field_v rexp "${cur}" expires if [[ "${rjob}" == "${job}" ]]; then plan_set "${ref}" "${cur}" "${new_oid}" || { duplicate_refusal "${p}" @@ -812,7 +871,7 @@ plan_claim() { # job holder ttl parent path... -> plans one claim; sets CLAIM path_ref ref "${p}" cur="$(ref_oid "${ref}")" [[ -z "${cur}" ]] && continue - rjob="$(field "${cur}" job)" + field_v rjob "${cur}" job in_list "${rjob}" "${evict[@]}" || continue T_AFTER["${ref}"]="${new_oid}" # planned as a delete by plan_terminate; the path passes to the new lock instead done @@ -907,8 +966,8 @@ claim_args() { # parses claim arguments into CA_JOB CA_HOLDER CA_TTL CA_PARENT C done [[ -n "${CA_JOB}" && -n "${CA_HOLDER}" ]] || usage valid_job "${CA_JOB}" || fail "job id '${CA_JOB}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 - [[ "${CA_TTL}" =~ ^[0-9]+$ && "${CA_TTL}" -gt 0 ]] || fail '--ttl is a positive number of seconds' 2 - [[ "${CA_HOLDER}" == *$'\n'* ]] && fail 'holder must be one line' 2 + valid_ttl CA_TTL "${CA_TTL}" || fail '--ttl is a positive number of seconds' 2 + valid_holder "${CA_HOLDER}" || fail 'holder must be one line' 2 ((${#CA_PATHS[@]} > 0)) || usage } @@ -920,7 +979,6 @@ cmd_claim() { commit_plan || exit 1 printf '%s\n' "${CLAIM_LINE}" } - # ---------------------------------------------------------------- batch cmd_batch() { @@ -928,11 +986,12 @@ cmd_batch() { local line key val job='' holder='' ttl='' parent='' paths=() in_paths=0 count=0 lines_out=() plan_reset finish_record() { - if [[ -z "${job}" && -z "${holder}" && ${#paths[@]} -eq 0 ]]; then return 0; fi + if [[ -z "${job}" && -z "${holder}" && -z "${ttl}" && -z "${parent}" && ${#paths[@]} -eq 0 ]]; then return 0; fi # only a wholly empty record is skipped; one with just parent: or ttl: is malformed [[ -n "${job}" && -n "${holder}" && ${#paths[@]} -gt 0 ]] || fail 'batch: every record needs job:, holder: and at least one path under paths:' 2 valid_job "${job}" || fail "batch: job id '${job}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 [[ -z "${ttl}" ]] && ttl="${DEFAULT_TTL}" - [[ "${ttl}" =~ ^[0-9]+$ && "${ttl}" -gt 0 ]] || fail 'batch: ttl is a positive number of seconds' 2 + valid_ttl ttl "${ttl}" || fail 'batch: ttl is a positive number of seconds' 2 + valid_holder "${holder}" || fail 'batch: holder must be one line' 2 plan_claim "${job}" "${holder}" "${ttl}" "${parent}" "${paths[@]}" lines_out+=("${CLAIM_LINE}") count=$((count + 1)) @@ -970,7 +1029,6 @@ cmd_batch() { commit_plan || exit 1 printf '%s\n' "${lines_out[@]}" } - # ---------------------------------------------------------------- release cmd_release() { @@ -1064,14 +1122,13 @@ cmd_release() { done return 0 } - # ---------------------------------------------------------------- check cmd_check() { ensure_snapshot # in this shell, so the $(…) reads below inherit one fresh snapshot instead of each taking their own (($# > 0)) || usage local at held=0 p n ref cur jp _j1 _j2 - at="$(now)" + now_v at for p in "$@"; do n="$(normalize_path "${p}")" || exit 2 path_ref ref "${n}" @@ -1093,18 +1150,17 @@ cmd_check() { done return "${held}" } - # ---------------------------------------------------------------- list / show / ttl -lock_line() { # oid -> one JSON line for list and show - ensure_snapshot # in this shell, so the $(…) reads below inherit one fresh snapshot instead of each taking their own +lock_line() { # oid -> one JSON line for list and show; no fork per line, so a list of n locks is O(n) bash and no processes + ensure_snapshot # in this shell, so the record parses below memoise here local _j1 _j2 _j3 _j4 jpaths claimed pj paths acq describe "$1" - claimed="$(field "$1" claimed)" - acq="$(field "$1" acquisition)" + field_v claimed "$1" claimed + field_v acq "$1" acquisition json_str _j4 "${acq}" - paths="$(record_paths "$1")" - json_paths jpaths <<<"${paths}" + record_paths_v paths "$1" + json_paths_v jpaths "${paths}" json_str _j1 "${D_JOB}" json_str _j2 "${D_HOLDER}" json_str _j3 "$1" @@ -1174,13 +1230,12 @@ cmd_ttl() { json_str _j1 "${D_JOB}" printf '{"job":%s,"expires":%s,"remaining":%s}\n' "${_j1}" "${D_EXPIRES}" "${D_REMAINING}" } - # ---------------------------------------------------------------- extend cmd_extend() { - local _j1 oid jref at expires record new_oid paths p ref have claimed parent family attempt acq + local _j1 oid jref at expires record new_oid paths p ref have claimed parent family attempt acq ttl job_arg "$@" - [[ "${TTL_ARG}" =~ ^[0-9]+$ && "${TTL_ARG}" -gt 0 ]] || fail '--ttl is a positive number of seconds' 2 + valid_ttl ttl "${TTL_ARG}" || fail '--ttl is a positive number of seconds' 2 jref="$(job_ref "${JOB_ARG}")" for ((attempt = 0; attempt < RETRIES; attempt++)); do snapshot @@ -1188,8 +1243,8 @@ cmd_extend() { oid="$(ref_oid "${jref}")" [[ -n "${oid}" ]] || missing "${JOB_ARG}" describe "${oid}" - at="$(now)" - expires=$((at + TTL_ARG)) + now_v at + expires=$((at + ttl)) paths="$(record_paths "${oid}")" claimed="$(field "${oid}" claimed)" parent="$(field "${oid}" parent)" @@ -1214,13 +1269,12 @@ cmd_extend() { json_str _j1 "${JOB_ARG}" printf '{"event":"extended","job":%s,"expires":%s}\n' "${_j1}" "${expires}" } - # ---------------------------------------------------------------- sweep cmd_sweep() { (($# == 0)) || usage local at ref oid rows rjob rholder rexpires _j1 _j2 attempt done_jobs=() still - at="$(now)" + now_v at rows="$(job_refs)" while IFS=' ' read -r ref oid; do [[ -z "${ref}" ]] && continue @@ -1235,7 +1289,8 @@ cmd_sweep() { snapshot plan_reset still="$(ref_oid "${ref}")" - [[ -n "${still}" ]] || break # gone meanwhile + [[ -n "${still}" ]] || break # gone meanwhile + [[ "${still}" == "${oid}" ]] || break # replaced or extended meanwhile: that is not the lock we saw expire plan_terminate "${rjob}" || fail "${PLAN_CONFLICT}" 1 if transact; then swept=1 @@ -1257,7 +1312,6 @@ cmd_sweep() { done <<<"${rows}" return 0 } - # ---------------------------------------------------------------- with acquire_with_wait() { # kind(lock|sem) wait-seconds errfile -> 0 acquired (ACQUIRED_LINE set), else exits with the refusal @@ -1354,9 +1408,32 @@ cmd_with() { ((${#command[@]} > 0)) || usage [[ -n "${W_SEM}" || ${#W_PATHS[@]} -gt 0 ]] || usage [[ "${wait}" =~ ^[0-9]+$ ]] || fail '--wait is a number of seconds' 2 + # Validate everything before acquiring anything: the semaphore path does not pass through claim_args or cmd_sem. + valid_job "${W_JOB}" || fail "job id '${W_JOB}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 + valid_holder "${W_HOLDER}" || fail 'holder must be one line' 2 + valid_ttl W_TTL "${W_TTL}" || fail '--ttl is a positive number of seconds' 2 + [[ -z "${W_SEM}" ]] || valid_job "${W_SEM}" || fail "semaphore name '${W_SEM}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 + [[ -z "${W_PARENT}" ]] || valid_job "${W_PARENT}" || fail "parent id '${W_PARENT}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 local errfile sem_record='' lock_record='' rc errfile="$(mktemp "${TMPDIR:-/tmp}/git-locks-with.XXXXXX")" || fail 'cannot create a temporary file' + + # Release exactly the acquisitions this invocation made, never whatever wears the job name now. Armed before the + # first acquisition: a signal while waiting for the lock must give back the slot already taken. + local status=0 + with_release_all() { + if [[ -n "${lock_record}" ]]; then + SNAP_LOADED=0 + (cmd_release --job "${W_JOB}" --acquisition "${lock_record}") >&2 + fi + if [[ -n "${sem_record}" ]]; then + with_release_sem "${sem_record}" + fi + rm -f "${errfile}" + return 0 + } + trap 'with_release_all; exit 130' INT + trap 'with_release_all; exit 143' TERM if [[ -n "${W_SEM}" ]]; then acquire_with_wait sem "${wait}" "${errfile}" rc=$? @@ -1380,20 +1457,6 @@ cmd_with() { fi rm -f "${errfile}" - # Release exactly the acquisitions this invocation made, never whatever wears the job name now. - local status=0 - with_release_all() { - if [[ -n "${lock_record}" ]]; then - SNAP_LOADED=0 - (cmd_release --job "${W_JOB}" --acquisition "${lock_record}") >&2 - fi - if [[ -n "${sem_record}" ]]; then - with_release_sem "${sem_record}" - fi - return 0 - } - trap 'with_release_all; exit 130' INT - trap 'with_release_all; exit 143' TERM "${command[@]}" || status=$? trap - INT TERM with_release_all @@ -1404,7 +1467,6 @@ with_release_sem() { # record -> releases this invocation's slot, if it is still SNAP_LOADED=0 (sem_release_once "${W_SEM}" "${W_JOB}" "$1") >&2 } - # ---------------------------------------------------------------- semaphores # # refs/locks/sem//meta holds the capacity; slots/ one record per @@ -1436,7 +1498,7 @@ sem_refusal() { # name reason [capacity live] gen_blob() { # VAR: a fresh generation token as a blob local at content - at="$(now)" + now_v at content="$(printf 'generation %s %s %s' "${at}" "$$" "${RANDOM}${RANDOM}")" write_blob "$1" "${content}" } @@ -1460,7 +1522,7 @@ sem_read() { # name -> 0, or 1 when the semaphore does not exist SLOT_EXPIRES=() SLOT_REMAINING=() SEM_LIVE=0 - at="$(now)" + now_v at rows="$(refs_under "${NS}/sem/${name}/slots/")" while IFS=' ' read -r ref oid; do [[ -z "${ref}" ]] && continue @@ -1550,7 +1612,7 @@ sem_acquire_attempt() { # one read-plan-transact; 0 acquired, 1 refused (capacit sem_refusal "${name}" capacity "${SEM_CAP}" "${SEM_LIVE}" return 1 fi - at="$(now)" + now_v at expires=$((at + ttl)) record="$(printf 'schema: %s\nsemaphore: %s\njob: %s\nholder: %s\nclaimed: %s\nexpires: %s\nacquisition: %s' "${SLOT_SCHEMA}" "${name}" "${job}" "${holder}" "${at}" "${expires}" "${acq}")" write_blob oid "${record}" || fail 'could not write the slot record' @@ -1693,7 +1755,7 @@ cmd_sem() { exit 1 fi local at meta gen mref gref content - at="$(now)" + now_v at content="$(printf 'schema: %s\nsemaphore: %s\ncapacity: %s\ncreated: %s' "${SEM_SCHEMA}" "${name}" "${capacity}" "${at}")" write_blob meta "${content}" || fail 'could not write the semaphore record' gen_blob gen || fail 'could not write the generation token' @@ -1712,7 +1774,8 @@ cmd_sem() { acquire) [[ -n "${job}" && -n "${holder}" ]] || usage valid_job "${job}" || fail "job id '${job}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 - [[ "${ttl}" =~ ^[0-9]+$ && "${ttl}" -gt 0 ]] || fail '--ttl is a positive number of seconds' 2 + valid_ttl ttl "${ttl}" || fail '--ttl is a positive number of seconds' 2 + valid_holder "${holder}" || fail 'holder must be one line' 2 [[ "${wait}" =~ ^[0-9]+$ ]] || fail '--wait is a number of seconds' 2 W_SEM="${name}" W_JOB="${job}" @@ -1769,9 +1832,7 @@ cmd_sem() { *) usage ;; esac } - # ---------------------------------------------------------------- schema, store, main - cmd_schema() { # the public output schema, one JSON line; the pretty form is schema/git-locks.schema.json in the repository (($# == 0)) || usage cat <<'EOF' @@ -1779,6 +1840,8 @@ cmd_schema() { # the public output schema, one JSON line; the pretty form is sch EOF } +# ---------------------------------------------------------------- store, main + cmd_store() { local _j1 (($# == 0)) || usage @@ -1801,6 +1864,7 @@ main() { exit 0 ;; version | --version) + (($# == 0)) || usage printf '{"name":"git-locks","version":"%s"}\n' "${VERSION}" exit 0 ;; diff --git a/lib/000-prelude.sh b/lib/000-prelude.sh new file mode 100644 index 0000000..61604d2 --- /dev/null +++ b/lib/000-prelude.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# git-locks — declare the paths you are about to write, as refs in a store. +# +# git locks claim --job --holder [--ttl ] [--parent ] ... +# git locks batch < records several claims in ONE transaction, all or nothing +# git locks release --job [--record | --acquisition ] [--job ...] +# git locks check ... exit 1 if any path is held +# git locks list +# git locks sweep delete expired locks +# git locks store print the store this directory resolves to +# git locks show --job one lock in full, with the seconds it has left +# git locks ttl --job just the seconds left +# git locks extend --job --ttl +# git locks with --job --holder [--ttl ] [--wait ] [--sem ] [...] -- ... +# git locks sem create|acquire|release|show|list|delete capacity semaphores +# git locks help | schema | version +# +# Works inside or outside a git repository: the default store is keyed on the +# repository's main git dir when there is one, else on the directory itself. +# +# Output is JSON Lines by default: one object per result on stdout, written as +# each result is known, refusals and errors as objects on stderr; every line +# conforms to schema/git-locks.schema.json, which `git locks schema` prints +# as one line. There is no plain-text mode. The one exception, stated: a command +# wrapped by `with` owns stdout; git-locks reports around it on stderr. +# +# Where the locks live: NOT in the working repository by default. The store is +# a bare repository at $GIT_LOCKS_HOME/locks/ +# ($GIT_LOCKS_HOME defaults to ~/.git-stunts), created on first use, so a +# project's own refs stay clean. Override with GIT_LOCKS_STORE= or +# GIT_LOCKS_STORE=self (the subject's own common git dir, shared by its +# worktrees), or persistently with `git config locks.store `. +# Precedence: environment, then config, then the default. +# +# A lock is one blob (a plain-text record: job, holder, claimed, expires, +# optional parent, a family generation, and the paths) pointed at by +# refs/locks/jobs/ and by refs/locks/paths/ for every path, where +# is git's own hash of the normalised path string. Every command reads the +# store once (for-each-ref plus one cat-file --batch) and compiles its intent +# into one transition per ref (create, update from an expected old value, +# delete with an expected old value, or verify), sent as a single +# `git update-ref --stdin` transaction. A stale expectation fails the whole +# transaction; commands that can re-plan do so a bounded number of times. +# +# Exit codes: 0 done (or free), 1 refused / held, 2 usage or a store error. +# GIT_LOCKS_NOW= fixes the clock (tests). +# GIT_LOCKS_PAUSE_BEFORE_COMMIT= makes every transaction wait for that +# file to exist before committing; GIT_LOCKS_PAUSE_AFTER_READ= makes +# every store read wait after loading; GIT_LOCKS_TRACE= appends one line +# per store read. Tests force interleavings and count reads with them. +set -uo pipefail +if ((BASH_VERSINFO[0] < 4)); then + printf 'git-locks: needs bash 4 or newer (associative arrays); this is %s\n' "${BASH_VERSION}" >&2 + exit 2 +fi +export LC_ALL=C # string offsets below are byte offsets: cat-file --batch sizes are bytes + +NS='refs/locks' +DEFAULT_TTL=14400 +SCHEMA='git-locks/1' +SEM_SCHEMA='git-locks-sem/1' +SLOT_SCHEMA='git-locks-slot/1' +VERSION='0.4.0' +RETRIES=200 # a plan refused for a stale expectation is re-read and re-planned this many times +NOW_CACHED='' # the clock, read once per invocation by now() + +usage_text() { + cat <<'EOF' +usage: git locks claim --job --holder [--ttl ] [--parent ] ... + git locks batch < records several claims in ONE transaction, all or nothing + git locks release --job [--record | --acquisition ] [--job ...] + git locks check ... + git locks list + git locks sweep + git locks store + git locks show --job + git locks ttl --job + git locks extend --job --ttl + git locks with --job --holder [--ttl ] [--wait ] [--sem ] [...] -- ... + git locks sem create --capacity | acquire --job --holder [--ttl ] [--wait ] + | release --job [--record | --acquisition ] | show | list | delete + git locks version + git locks help | schema + +claim lock the paths for the job, atomically; re-claiming with the same job replaces its path set and + its record; --parent makes it a child: the parent must be live and held by the same holder, and the + child is released or swept with it. The claim line carries the record id of this acquisition. +batch read lock records on stdin (blank-line separated: job:, holder:, ttl:, parent:, paths: then + one path per line) and claim them all in one transaction, or none +release drop the named jobs' locks and all their descendants, in one transaction; --acquisition releases + only if the job's current record belongs to that acquisition (an id that survives extend), --record + only if the record oid is exactly that one +check who holds each path, with the seconds left; exit 1 if any is held +list every lock, live or expired, with its paths and the seconds left +sweep delete expired locks, each with its descendants +store print the store this directory resolves to +show one lock in full; exit 1 if there is none +ttl the seconds a lock has left; exit 1 if there is none +extend move a lock's expiry to now + ttl, keeping its paths and family +with claim, run the command, release the acquisition it made (also on failure or a signal), exit with + the command's status; --wait retries once a second until the paths are free or the wait runs out. + The command's stdout is its own; git-locks reports its claim and release on stderr. The lock is a + time-bounded reservation: with does not renew it, so give --ttl the command's worst case. +sem capacity, not exclusivity: up to jobs hold a named semaphore at once; a slot expires like a + lock; acquire is one transaction with a compare-and-swap on the semaphore's generation, so racers + beyond capacity fail and exactly win +schema print the JSON Schema every output line conforms to + +output: JSON Lines, always: one object per result on stdout, written as each result is known; + refusals and errors are objects on stderr; help is a usage object; schema is the schema on one + line. A command wrapped by with owns stdout. +store: GIT_LOCKS_STORE=, else `git config locks.store`, + else ${GIT_LOCKS_HOME:-~/.git-stunts}/locks/
+clock: GIT_LOCKS_NOW= (tests) +exit: 0 done or free, 1 refused or held, 2 usage or a store error +EOF +} + +usage_json() { # VAR: the usage object + local text _j1 + text="$(usage_text)" + json_str _j1 "${text}" + printf -v "$1" '{"event":"usage","usage":%s}' "${_j1}" +} + +usage() { # a usage error: the usage object on stderr, exit 2 + local line + usage_json line + printf '%s\n' "${line}" >&2 + exit 2 +} + +sub_usage() { # subcommand -> its usage as a usage object on stdout + local text _j1 + text="$(sub_usage_text "$1")" + json_str _j1 "${text}" + printf '{"event":"usage","usage":%s}\n' "${_j1}" +} + +sub_usage_text() { + case "$1" in + claim) printf 'usage: git locks claim --job --holder [--ttl ] [--parent ] ...\n' ;; + batch) printf 'usage: git locks batch < records\n' ;; + release) printf 'usage: git locks release --job [--record | --acquisition ] [--job ...]\n' ;; + check) printf 'usage: git locks check ...\n' ;; + list) printf 'usage: git locks list\n' ;; + sweep) printf 'usage: git locks sweep\n' ;; + store) printf 'usage: git locks store\n' ;; + show) printf 'usage: git locks show --job \n' ;; + ttl) printf 'usage: git locks ttl --job \n' ;; + extend) printf 'usage: git locks extend --job --ttl \n' ;; + with) printf 'usage: git locks with --job --holder [--ttl ] [--wait ] [--sem ] [...] -- ...\n' ;; + sem) printf 'usage: git locks sem create --capacity | acquire --job --holder [--ttl ] [--wait ] | release --job [--record | --acquisition ] | show | list | delete \n' ;; + *) usage_text ;; + esac +} diff --git a/lib/010-json.sh b/lib/010-json.sh new file mode 100644 index 0000000..1d08881 --- /dev/null +++ b/lib/010-json.sh @@ -0,0 +1,73 @@ +# ---------------------------------------------------------------- JSON + +json_str() { # VAR VALUE: set VAR to VALUE as a JSON string, every control character escaped + local s="$2" out='' i c code + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + if [[ "${s}" == *[[:cntrl:]]* ]]; then + for ((i = 0; i < ${#s}; i++)); do + c="${s:i:1}" + case "${c}" in + $'\n') out+='\n' ;; + $'\r') out+='\r' ;; + $'\t') out+='\t' ;; + [[:cntrl:]]) + printf -v code '%d' "'${c}" + printf -v c '\\u%04x' "${code}" + out+="${c}" + ;; + *) out+="${c}" ;; + esac + done + s="${out}" + fi + printf -v "$1" '"%s"' "${s}" +} + +json_paths() { # VAR: set VAR to a JSON array of the lines on stdin + local line items=() one IFS + while IFS= read -r line; do + if [[ -n "${line}" ]]; then + json_str one "${line}" + items+=("${one}") + fi + done + IFS=',' + printf -v "$1" '[%s]' "${items[*]}" +} + +json_paths_v() { # VAR TEXT: set VAR to a JSON array of TEXT's non-empty lines; no fork + local text="$2" line items=() one IFS + while [[ -n "${text}" ]]; do + line="${text%%$'\n'*}" + if [[ "${line}" == "${text}" ]]; then text=''; else text="${text#*$'\n'}"; fi + if [[ -n "${line}" ]]; then + json_str one "${line}" + items+=("${one}") + fi + done + IFS=',' + printf -v "$1" '[%s]' "${items[*]}" +} + +json_jobs() { # VAR job... -> JSON array of job ids + local var="$1" one items=() IFS j + shift + for j in "$@"; do + json_str one "${j}" + items+=("${one}") + done + IFS=',' + printf -v "${var}" '[%s]' "${items[*]}" +} + +parent_json() { # VAR oid -> ',"parent":""' or '' when the record has no parent + local p one + field_v p "$2" parent + if [[ -n "${p}" ]]; then + json_str one "${p}" + printf -v "$1" ',"parent":%s' "${one}" + else + printf -v "$1" '' + fi +} diff --git a/lib/020-errors.sh b/lib/020-errors.sh new file mode 100644 index 0000000..025a533 --- /dev/null +++ b/lib/020-errors.sh @@ -0,0 +1,16 @@ +# ---------------------------------------------------------------- errors + +fail() { # message [code]: an error line on stderr, then exit (2 is usage, 1 is a failed operation) + local code="${2:-1}" reason _j1 + if ((code == 2)); then reason='usage'; else reason='failed'; fi + json_str _j1 "$1" + printf '{"event":"error","reason":"%s","detail":%s}\n' "${reason}" "${_j1}" >&2 + exit "${code}" +} + +store_error() { # detail: the store could not be read; nothing is reported as free or held + local _j1 + json_str _j1 "$1" + printf '{"event":"error","reason":"store-read","detail":%s}\n' "${_j1}" >&2 + exit 2 +} diff --git a/lib/030-time-refs-records.sh b/lib/030-time-refs-records.sh new file mode 100644 index 0000000..aa514a7 --- /dev/null +++ b/lib/030-time-refs-records.sh @@ -0,0 +1,78 @@ +# ---------------------------------------------------------------- time, refs, records + +now_v() { # VAR: set VAR to the clock, read once per invocation; no fork after the first call + if [[ -z "${NOW_CACHED}" ]]; then + if [[ -n "${GIT_LOCKS_NOW:-}" ]]; then + NOW_CACHED="${GIT_LOCKS_NOW}" + else + NOW_CACHED="$(date +%s)" + fi + fi + printf -v "$1" '%s' "${NOW_CACHED}" +} + +now() { + local v + now_v v + printf '%s' "${v}" +} + +declare -A PATH_HASH=() # path -> git's hash of the path string, memoised per invocation + +path_ref() { # VAR path: set VAR to the path's ref; the hash is memoised in this shell (never call inside $(…)) + local h + if [[ -z "${PATH_HASH[$2]+x}" ]]; then + h="$(printf '%s' "$2" | g hash-object --stdin)" || return 1 + PATH_HASH["$2"]="${h}" + fi + printf -v "$1" '%s/paths/%s' "${NS}" "${PATH_HASH[$2]}" +} + +job_ref() { printf '%s/jobs/%s' "${NS}" "$1"; } + +valid_job() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; } + +valid_holder() { [[ -n "$1" && "$1" != *$'\n'* && "$1" != *$'\r'* ]]; } # one line: the record is line-oriented; any other byte is stored whole and escaped on output + +valid_ttl() { # VAR value: VAR = the value as a decimal number of seconds; 1 unless it is digits only and positive (010 is ten, never octal eight) + [[ "$2" =~ ^[0-9]+$ ]] || return 1 + local _vt=$((10#$2)) + ((_vt > 0)) || return 1 + printf -v "$1" '%s' "${_vt}" +} + +valid_oid() { [[ "$1" =~ ^[0-9a-f]{40}([0-9a-f]{24})?$ ]]; } + +path_error() { # detail -> a usage error line on stderr (the caller returns 2) + local _j1 + json_str _j1 "$1" + printf '{"event":"error","reason":"usage","detail":%s}\n' "${_j1}" >&2 +} + +normalize_path() { # -> prints the lexical form, or returns 2 with the reason on stderr + # Policy, stated: leading ./, empty segments (//), single-dot segments and a + # trailing / are removed; absolute paths and .. segments are refused; case, + # symlinks and hard links are NOT resolved. dir/ and dir/file are different keys. + local p="$1" part parts=() IFS='/' + [[ "${p}" == /* ]] && { + path_error "${p}: paths are repo-relative" + return 2 + } + [[ "${p}" == *$'\n'* ]] && { + path_error 'a path with a newline is not supported' + return 2 + } + for part in ${p}; do + [[ -z "${part}" || "${part}" == '.' ]] && continue + [[ "${part}" == '..' ]] && { + path_error "${p}: no .. components" + return 2 + } + parts+=("${part}") + done + ((${#parts[@]} > 0)) || { + path_error 'an empty path' + return 2 + } + printf '%s' "${parts[*]}" +} diff --git a/lib/040-the-store.sh b/lib/040-the-store.sh new file mode 100644 index 0000000..ecbe463 --- /dev/null +++ b/lib/040-the-store.sh @@ -0,0 +1,28 @@ +# ---------------------------------------------------------------- the store + +STORE='' + +resolve_store() { # sets STORE; creates the default or a custom store on first use + local common='' sel key + common="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" || common='' # empty outside a repository + sel="${GIT_LOCKS_STORE:-}" + if [[ -z "${sel}" ]]; then + sel="$(git config --get locks.store 2>/dev/null)" || sel='' + fi + if [[ -n "${common}" ]]; then key="${common%/.git}"; else key="${PWD}"; fi # main repo when there is one, else the directory + case "${sel}" in + '') STORE="${GIT_LOCKS_HOME:-${HOME}/.git-stunts}/locks${key}" ;; + self) + [[ -n "${common}" ]] || fail 'GIT_LOCKS_STORE=self needs a git repository; this directory is not in one' 2 + STORE="${common}" + ;; + /*) STORE="${sel}" ;; + *) STORE="${PWD}/${sel}" ;; + esac + if [[ "${STORE}" != "${common}" && ! -f "${STORE}/HEAD" ]]; then # a repository, bare or not, has a HEAD + mkdir -p "${STORE}" || fail "cannot create the lock store at ${STORE}" 2 + git init -q --bare "${STORE}" || fail "cannot initialise the lock store at ${STORE}" 2 + fi +} + +g() { git --git-dir="${STORE}" "$@"; } diff --git a/lib/050-the-snapshot.sh b/lib/050-the-snapshot.sh new file mode 100644 index 0000000..11041b4 --- /dev/null +++ b/lib/050-the-snapshot.sh @@ -0,0 +1,167 @@ +# ---------------------------------------------------------------- the snapshot +# +# One for-each-ref and one cat-file --batch per invocation; every reader below +# comes from these two arrays. A read that fails, or an object that does not +# parse, is a store error: it is never reported as "free". Every transaction +# invalidates the snapshot; the next read takes a fresh one. A snapshot is a +# cached read taken under one for-each-ref, not a proof of a consistent cut; +# every write below carries the expectations that make a stale read fail. + +declare -A REF_OID=() # ref -> oid +declare -A BLOB=() # oid -> record text +declare -A R_PARSED=() # oid -> 1 once parsed +declare -A R_FIELD=() # "oid key" -> value, for the header lines before paths: (first occurrence wins); values are stored whole, so no byte in one can read as a delimiter +declare -A R_PATHS=() # oid -> the path lines, newline separated +SNAP_LOADED=0 + +parse_record() { # oid -> R_FIELD["oid key"] and R_PATHS[oid] from BLOB[oid], once per shell; parameter expansion only, no fork + [[ -n "${R_PARSED[$1]+x}" ]] && return 0 + local text="${BLOB[$1]:-}" line key paths='' in_paths=0 + while [[ -n "${text}" ]]; do + line="${text%%$'\n'*}" + if [[ "${line}" == "${text}" ]]; then text=''; else text="${text#*$'\n'}"; fi + if ((in_paths)); then + paths+="${line}"$'\n' + elif [[ "${line}" == 'paths:' ]]; then + in_paths=1 + elif [[ "${line}" == *': '* ]]; then + key="${line%%: *}" + [[ -n "${R_FIELD["$1 ${key}"]+x}" ]] || R_FIELD["$1 ${key}"]="${line#*: }" + fi + done + R_PARSED["$1"]=1 + R_PATHS["$1"]="${paths%$'\n'}" + [[ -n "${GIT_LOCKS_TRACE:-}" ]] && printf 'parse %s\n' "$1" >>"${GIT_LOCKS_TRACE}" + return 0 +} + +snapshot() { + local -A refs=() blobs=() + local rows ref oid oids=() rc + rows="$(g for-each-ref --format='%(refname) %(objectname)' "${NS}/" 2>&1)" + rc=$? + ((rc == 0)) || store_error "for-each-ref exited ${rc}: ${rows}" + while IFS=' ' read -r ref oid; do + [[ -z "${ref}" ]] && continue + valid_oid "${oid}" || store_error "for-each-ref line does not parse: ${ref} ${oid}" + refs["${ref}"]="${oid}" + oids+=("${oid}") + done <<<"${rows}" + if ((${#oids[@]} > 0)); then + local out + out="$(printf '%s\n' "${oids[@]}" | sort -u | g cat-file --batch 2>&1 && printf x)" # the x keeps trailing newlines + rc=$? + ((rc == 0)) || store_error "cat-file --batch exited ${rc}: ${out%x}" + out="${out%x}" + local header size body nl + # read -N over the captured text is linear; slicing ${out:pos:size} copies from pos every time and is quadratic in the store + while IFS= read -r header; do + [[ "${header}" =~ ^([0-9a-f]+)\ blob\ ([0-9]+)$ ]] || store_error "cat-file --batch header does not parse: ${header}" + oid="${BASH_REMATCH[1]}" + size="${BASH_REMATCH[2]}" + body='' + ((size > 0)) && { IFS= read -r -N "${size}" body || store_error "cat-file --batch object ${oid} is short"; } + IFS= read -r -N 1 nl || nl='' + [[ "${nl}" == $'\n' ]] || store_error "cat-file --batch object ${oid} is not newline terminated" + blobs["${oid}"]="${body}" + done <<<"${out%$'\n'}" + for oid in "${oids[@]}"; do + [[ -n "${blobs[${oid}]+x}" ]] || store_error "object ${oid} named by a ref is missing from the store" + done + fi + REF_OID=() + BLOB=() + R_PARSED=() + R_FIELD=() + R_PATHS=() + for ref in "${!refs[@]}"; do REF_OID["${ref}"]="${refs[${ref}]}"; done + for oid in "${!blobs[@]}"; do BLOB["${oid}"]="${blobs[${oid}]}"; done + SNAP_LOADED=1 + [[ -n "${GIT_LOCKS_TRACE:-}" ]] && printf 'snapshot %s\n' "${#refs[@]}" >>"${GIT_LOCKS_TRACE}" + test_gate "${GIT_LOCKS_PAUSE_AFTER_READ:-}" # tests force an interleaving between a read and what follows it +} + +test_gate() { # file-or-empty: when set, wait here until the file exists (at most 30 s); tests only + [[ -n "$1" ]] || return 0 + local waited=0 + until [[ -e "$1" ]] || ((waited >= 600)); do + sleep 0.05 + waited=$((waited + 1)) + done + return 0 +} + +ensure_snapshot() { ((SNAP_LOADED)) || snapshot; } + +ref_oid() { # ref -> oid or empty + ensure_snapshot + printf '%s' "${REF_OID[$1]:-}" +} + +field_v() { # VAR oid key: set VAR to the value of `key:` in the record's header (before paths:), empty when absent; no fork, so the parse memoises in this shell + ensure_snapshot + parse_record "$2" + printf -v "$1" '%s' "${R_FIELD["$2 $3"]:-}" +} + +field() { # oid key -> the value on stdout; inside $(…) the parse happens in the subshell, so hot paths use field_v + local v + field_v v "$1" "$2" + printf '%s' "${v}" +} + +record_paths_v() { # VAR oid: set VAR to the paths, newline separated; no fork + ensure_snapshot + parse_record "$2" + printf -v "$1" '%s' "${R_PATHS[$2]:-}" +} + +record_paths() { # oid -> paths, one per line + local v + record_paths_v v "$1" + [[ -n "${v}" ]] && printf '%s\n' "${v}" + return 0 +} + +refs_under() { # prefix -> "ref oid" lines, sorted by ref, from the snapshot + ensure_snapshot + local ref + for ref in "${!REF_OID[@]}"; do + [[ "${ref}" == "$1"* ]] && printf '%s %s\n' "${ref}" "${REF_OID[${ref}]}" + done | sort +} + +job_refs() { refs_under "${NS}/jobs/"; } + +D_HOLDER='' +D_JOB='' +D_EXPIRES=0 +D_REMAINING=0 +D_STATE='' + +describe() { # oid -> D_HOLDER D_JOB D_EXPIRES D_REMAINING D_STATE; no fork + field_v D_HOLDER "$1" holder + field_v D_JOB "$1" job + local exp at + field_v exp "$1" expires + D_EXPIRES="${exp:-0}" + now_v at + D_REMAINING=$((D_EXPIRES - at)) + ((D_REMAINING < 0)) && D_REMAINING=0 + if ((D_EXPIRES > at)); then D_STATE='live'; else D_STATE='expired'; fi +} + +in_list() { # needle list... + local needle="$1" item + shift + for item in "$@"; do [[ "${item}" == "${needle}" ]] && return 0; done + return 1 +} + +write_blob() { # VAR CONTENT: write CONTENT as a blob, seed the snapshot with it, set VAR to its oid + local written # not `oid`: printf -v writes to the caller's variable of that name, which a local would shadow + written="$(printf '%s\n' "$2" | g hash-object -w --stdin)" || return 1 + ensure_snapshot + BLOB["${written}"]="$2"$'\n' + printf -v "$1" '%s' "${written}" +} diff --git a/lib/060-the-transition-plan.sh b/lib/060-the-transition-plan.sh new file mode 100644 index 0000000..cfbfd14 --- /dev/null +++ b/lib/060-the-transition-plan.sh @@ -0,0 +1,76 @@ +# ---------------------------------------------------------------- the transition plan +# +# One final transition per ref. Every writer says what it expects a ref to hold +# now (an oid, or absent) and what it should hold after (an oid, absent, or the +# same: a verify). Two statements about one ref must agree on the expectation; +# a later statement may only sharpen an earlier verify into a change. Anything +# else is a contradiction found here, in planning, never by git. + +declare -A T_BEFORE=() # ref -> expected current oid, or '' for absent +declare -A T_AFTER=() # ref -> resulting oid, '' for delete, '=' for verify only +PLAN_ORDER=() +PLAN_CONFLICT='' + +plan_reset() { + T_BEFORE=() + T_AFTER=() + PLAN_ORDER=() + PLAN_CONFLICT='' +} + +plan_set() { # ref before after -> 0, or 1 with PLAN_CONFLICT set + local ref="$1" before="$2" after="$3" + if [[ -z "${T_BEFORE[${ref}]+x}" ]]; then + T_BEFORE["${ref}"]="${before}" + T_AFTER["${ref}"]="${after}" + PLAN_ORDER+=("${ref}") + return 0 + fi + if [[ "${T_BEFORE[${ref}]}" != "${before}" ]]; then + PLAN_CONFLICT="two expectations for ${ref}" + return 1 + fi + local have="${T_AFTER[${ref}]}" + if [[ "${have}" == '=' ]]; then + T_AFTER["${ref}"]="${after}" + return 0 + fi + [[ "${after}" == '=' || "${after}" == "${have}" ]] && return 0 + PLAN_CONFLICT="two transitions for ${ref}" + return 1 +} + +plan_lines() { # -> update-ref stdin lines, one per ref, in plan order + local ref before after + for ref in "${PLAN_ORDER[@]}"; do + before="${T_BEFORE[${ref}]}" + after="${T_AFTER[${ref}]}" + if [[ "${after}" == '=' ]]; then + [[ -n "${before}" ]] && printf 'verify %s %s\n' "${ref}" "${before}" + elif [[ -z "${after}" ]]; then + [[ -n "${before}" ]] && printf 'delete %s %s\n' "${ref}" "${before}" + elif [[ -z "${before}" ]]; then + printf 'create %s %s\n' "${ref}" "${after}" + else + printf 'update %s %s %s\n' "${ref}" "${after}" "${before}" + fi + done +} + +TRANSACT_ERR='' + +transact() { # commits the plan; 0 ok, 1 refused (TRANSACT_ERR carries git's words). Invalidates the snapshot either way. + local lines rc + lines="$(plan_lines)" + test_gate "${GIT_LOCKS_PAUSE_BEFORE_COMMIT:-}" # tests force an interleaving between planning and commit + TRANSACT_ERR="$( + { + printf 'start\n' + printf '%s\n' "${lines}" + printf 'prepare\ncommit\n' + } | g update-ref --stdin 2>&1 + )" + rc=$? + SNAP_LOADED=0 + ((rc == 0)) +} diff --git a/lib/070-refusals.sh b/lib/070-refusals.sh new file mode 100644 index 0000000..5e5d502 --- /dev/null +++ b/lib/070-refusals.sh @@ -0,0 +1,28 @@ +# ---------------------------------------------------------------- refusals + +refusal() { # path, after describe(): one refusal line on stderr + local _j1 _j2 _j3 + json_str _j1 "$1" + json_str _j2 "${D_HOLDER}" + json_str _j3 "${D_JOB}" + printf '{"event":"refused","path":%s,"holder":%s,"job":%s,"expires":%s}\n' "${_j1}" "${_j2}" "${_j3}" "${D_EXPIRES}" >&2 +} + +parent_refusal() { # child parent detail + local _j1 _j2 + json_str _j1 "$1" + json_str _j2 "$2" + printf '{"event":"refused","reason":"parent","job":%s,"parent":%s,"detail":"%s"}\n' "${_j1}" "${_j2}" "$3" >&2 +} + +duplicate_refusal() { # path named twice within one plan + local _j1 + json_str _j1 "$1" + printf '{"event":"refused","reason":"duplicate","path":%s}\n' "${_j1}" >&2 +} + +transaction_refusal() { # git's words, as one line + local _j1 + json_str _j1 "${TRANSACT_ERR}" + printf '{"event":"refused","reason":"transaction","detail":%s}\n' "${_j1}" >&2 +} diff --git a/lib/080-families.sh b/lib/080-families.sh new file mode 100644 index 0000000..ac0d362 --- /dev/null +++ b/lib/080-families.sh @@ -0,0 +1,119 @@ +# ---------------------------------------------------------------- families +# +# A child records `parent: `. The parent's record carries `family: `, +# a generation that every child admission increments by rewriting the parent's +# blob and moving the parent's job ref and path refs to it. So membership is +# part of the parent's own compare-and-swap: a release or sweep that planned +# against the parent's old blob fails when a child was admitted meanwhile, and +# re-plans with the child in view. A child cannot outlive its parent. + +descendants() { # job... -> DESC: every job whose parent chain reaches one of them (transitively), sorted + ensure_snapshot # in this shell, so the $(…) reads below inherit one fresh snapshot instead of each taking their own + DESC=() + local rows ref oid rjob rparent changed=1 seeds=("$@") j + rows="$(job_refs)" + local all_jobs=() all_parents=() + while IFS=' ' read -r ref oid; do + [[ -z "${ref}" ]] && continue + field_v rjob "${oid}" job + field_v rparent "${oid}" parent + all_jobs+=("${rjob}") + all_parents+=("${rparent}") + done <<<"${rows}" + local family=("${seeds[@]}") i + while ((changed)); do + changed=0 + for i in "${!all_jobs[@]}"; do + [[ -z "${all_parents[${i}]}" ]] && continue + in_list "${all_jobs[${i}]}" "${family[@]}" && continue + if in_list "${all_parents[${i}]}" "${family[@]}"; then + family+=("${all_jobs[${i}]}") + changed=1 + fi + done + done + for j in "${family[@]}"; do + in_list "${j}" "${seeds[@]}" || DESC+=("${j}") + done + if ((${#DESC[@]} > 0)); then + local sorted + sorted="$(printf '%s\n' "${DESC[@]}" | sort)" + DESC=() + while IFS= read -r j; do [[ -n "${j}" ]] && DESC+=("${j}"); done <<<"${sorted}" + fi +} + +plan_delete_job() { # job -> plans deletes for its job ref and the path refs still pointing at it; DELETED_PATHS = how many + ensure_snapshot # in this shell, so the $(…) reads below inherit one fresh snapshot instead of each taking their own + local jref oid p ref have paths count=0 + DELETED_PATHS=0 + jref="$(job_ref "$1")" + oid="$(ref_oid "${jref}")" + [[ -n "${oid}" ]] || return 0 + plan_set "${jref}" "${oid}" '' || return 1 + paths="$(record_paths "${oid}")" + while IFS= read -r p; do + [[ -z "${p}" ]] && continue + path_ref ref "${p}" + have="$(ref_oid "${ref}")" + if [[ "${have}" == "${oid}" ]]; then + plan_set "${ref}" "${oid}" '' || return 1 + count=$((count + 1)) + fi + done <<<"${paths}" + DELETED_PATHS="${count}" +} + +plan_terminate() { # job -> plans the deletion of the job and every descendant; TERMINATED_PATHS, TERMINATED_CASCADE (json array) + local d n + descendants "$1" + plan_delete_job "$1" || return 1 + n="${DELETED_PATHS}" + for d in "${DESC[@]}"; do + plan_delete_job "${d}" || return 1 + n=$((n + DELETED_PATHS)) + done + TERMINATED_PATHS="${n}" + json_jobs TERMINATED_CASCADE "${DESC[@]}" +} + +new_acquisition() { # VAR: a fresh acquisition id. The record oid changes on every rewrite (renewal, family bump); + local at # this id does not, so a caller can name the acquisition it made across renewals. + now_v at + printf -v "$1" '%s-%05d-%05d%05d' "${at}" "$$" "${RANDOM}" "${RANDOM}" +} + +record_text() { # VAR job holder claimed expires parent family acquisition paths-newline-separated + local body + body="$( + printf 'schema: %s\njob: %s\nholder: %s\nclaimed: %s\nexpires: %s\n' "${SCHEMA}" "$2" "$3" "$4" "$5" + [[ -n "$6" ]] && printf 'parent: %s\n' "$6" + printf 'family: %s\nacquisition: %s\npaths:\n%s' "$7" "$8" "$9" + )" + printf -v "$1" '%s' "${body}" +} + +bump_parent() { # parent-job parent-oid -> plans the parent's blob rewrite with family+1 on its job ref and path refs + ensure_snapshot # in this shell, so the $(…) reads below inherit one fresh snapshot instead of each taking their own + local pjob="$1" poid="$2" fam newfam claimed expires holder parent paths record newoid p ref have acq + fam="$(field "${poid}" family)" + acq="$(field "${poid}" acquisition)" + newfam=$((${fam:-0} + 1)) + holder="$(field "${poid}" holder)" + claimed="$(field "${poid}" claimed)" + expires="$(field "${poid}" expires)" + parent="$(field "${poid}" parent)" + paths="$(record_paths "${poid}")" + record_text record "${pjob}" "${holder}" "${claimed}" "${expires}" "${parent}" "${newfam}" "${acq}" "${paths}" + write_blob newoid "${record}" || fail 'could not write the parent record' + local pjref + pjref="$(job_ref "${pjob}")" + plan_set "${pjref}" "${poid}" "${newoid}" || return 1 + while IFS= read -r p; do + [[ -z "${p}" ]] && continue + path_ref ref "${p}" + have="$(ref_oid "${ref}")" + [[ "${have}" == "${poid}" ]] && { plan_set "${ref}" "${poid}" "${newoid}" || return 1; } + done <<<"${paths}" + return 0 +} diff --git a/lib/090-claim-planning.sh b/lib/090-claim-planning.sh new file mode 100644 index 0000000..96eb8fc --- /dev/null +++ b/lib/090-claim-planning.sh @@ -0,0 +1,240 @@ +# ---------------------------------------------------------------- claim planning + +BATCH_JOBS=() +declare -A BATCH_HOLDER=() # job planned in this batch -> holder +declare -A BUMPED=() # parent job -> 1 once its family generation is planned in this batch +CONFLICTS=0 +CLAIM_LINE='' +TERMINATED_PATHS=0 +TERMINATED_CASCADE='[]' + +plan_claim() { # job holder ttl parent path... -> plans one claim; sets CLAIM_LINE/CLAIM_OID; CONFLICTS=1 on refusal + ensure_snapshot # in this shell, so the $(…) reads below inherit one fresh snapshot instead of each taking their own + local job="$1" holder="$2" ttl="$3" parent="$4" + shift 4 + local paths=("$@") p n norm=() sorted wanted=() + for p in "${paths[@]}"; do + n="$(normalize_path "${p}")" || exit 2 + norm+=("${n}") + done + sorted="$(printf '%s\n' "${norm[@]}" | sort -u)" + while IFS= read -r n; do [[ -n "${n}" ]] && wanted+=("${n}"); done <<<"${sorted}" + + local at expires + now_v at + expires=$((at + ttl)) + + # The parent, if any: live and the same holder, whether it exists already or is planned earlier in this batch. + local pref poid + if [[ -n "${parent}" ]]; then + valid_job "${parent}" || fail "parent id '${parent}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 + if in_list "${parent}" "${BATCH_JOBS[@]}"; then + if [[ "${BATCH_HOLDER[${parent}]}" != "${holder}" ]]; then + parent_refusal "${job}" "${parent}" holder + CONFLICTS=1 + return 0 + fi + else + pref="$(job_ref "${parent}")" + poid="$(ref_oid "${pref}")" + if [[ -z "${poid}" ]]; then + parent_refusal "${job}" "${parent}" missing + CONFLICTS=1 + return 0 + fi + describe "${poid}" + if [[ "${D_STATE}" != live ]]; then + parent_refusal "${job}" "${parent}" expired + CONFLICTS=1 + return 0 + fi + if [[ "${D_HOLDER}" != "${holder}" ]]; then + parent_refusal "${job}" "${parent}" holder + CONFLICTS=1 + return 0 + fi + if [[ -z "${BUMPED[${parent}]+x}" ]]; then + bump_parent "${parent}" "${poid}" || { + fail "${PLAN_CONFLICT}" 1 + } + BUMPED["${parent}"]=1 + fi + fi + fi + + local jref old_job_oid old_family='0' + jref="$(job_ref "${job}")" + old_job_oid="$(ref_oid "${jref}")" + [[ -n "${old_job_oid}" ]] && old_family="$(field "${old_job_oid}" family)" + local record new_oid acq joined + new_acquisition acq + joined="$(printf '%s\n' "${wanted[@]}")" + record_text record "${job}" "${holder}" "${at}" "${expires}" "${parent}" "${old_family:-0}" "${acq}" "${joined}" + write_blob new_oid "${record}" || fail 'could not write the lock record' + + local evict=() ref cur rjob rexp + for p in "${wanted[@]}"; do + path_ref ref "${p}" + cur="$(ref_oid "${ref}")" + if [[ -z "${cur}" ]]; then + if [[ -n "${T_BEFORE[${ref}]+x}" && "${T_AFTER[${ref}]}" != '=' ]]; then + duplicate_refusal "${p}" # another record in this batch already takes it + CONFLICTS=1 + continue + fi + plan_set "${ref}" '' "${new_oid}" || { + duplicate_refusal "${p}" + CONFLICTS=1 + } + continue + fi + field_v rjob "${cur}" job + field_v rexp "${cur}" expires + if [[ "${rjob}" == "${job}" ]]; then + plan_set "${ref}" "${cur}" "${new_oid}" || { + duplicate_refusal "${p}" + CONFLICTS=1 + } + elif [[ -n "${rexp}" && "${rexp}" -le "${at}" ]]; then + in_list "${rjob}" "${evict[@]}" || evict+=("${rjob}") + else + describe "${cur}" + refusal "${p}" + CONFLICTS=1 + fi + done + + # The job's own ref, and paths it held before but no longer lists. + if [[ -n "${old_job_oid}" ]]; then + plan_set "${jref}" "${old_job_oid}" "${new_oid}" || fail "${PLAN_CONFLICT}" 1 + local old_paths have + old_paths="$(record_paths "${old_job_oid}")" + while IFS= read -r p; do + [[ -z "${p}" ]] && continue + in_list "${p}" "${wanted[@]}" && continue + path_ref ref "${p}" + have="$(ref_oid "${ref}")" + [[ "${have}" == "${old_job_oid}" ]] && { plan_set "${ref}" "${old_job_oid}" '' || fail "${PLAN_CONFLICT}" 1; } + done <<<"${old_paths}" + else + plan_set "${jref}" '' "${new_oid}" || fail "${PLAN_CONFLICT}" 1 + fi + + # An expired lock in the way is terminated whole, descendants included, the same way release and sweep do it; + # then the wanted paths it held move to the new record. + local ej + for ej in "${evict[@]}"; do + plan_terminate "${ej}" || fail "${PLAN_CONFLICT}" 1 + done + for p in "${wanted[@]}"; do + path_ref ref "${p}" + cur="$(ref_oid "${ref}")" + [[ -z "${cur}" ]] && continue + field_v rjob "${cur}" job + in_list "${rjob}" "${evict[@]}" || continue + T_AFTER["${ref}"]="${new_oid}" # planned as a delete by plan_terminate; the path passes to the new lock instead + done + + BATCH_JOBS+=("${job}") + BATCH_HOLDER["${job}"]="${holder}" + local jpaths _j1 _j2 _j3 _j4 pj='' + json_paths jpaths < <(printf '%s\n' "${wanted[@]}") + json_str _j1 "${job}" + json_str _j2 "${holder}" + json_str _j3 "${new_oid}" + json_str _j4 "${acq}" + if [[ -n "${parent}" ]]; then + json_str pj "${parent}" + pj=",\"parent\":${pj}" + fi + CLAIM_LINE="{\"event\":\"claimed\",\"job\":${_j1},\"holder\":${_j2},\"claimed\":${at},\"expires\":${expires}${pj},\"paths\":${jpaths},\"record\":${_j3},\"acquisition\":${_j4}}" + return 0 +} + +ref_path() { # oid ref -> which of the record's paths hashes to this ref (for naming a lost race) + local p pr paths + paths="$(record_paths "$1")" + while IFS= read -r p; do + [[ -z "${p}" ]] && continue + path_ref pr "${p}" + if [[ "${pr}" == "$2" ]]; then + printf '%s' "${p}" + return 0 + fi + done <<<"${paths}" + return 0 +} + +commit_plan() { # -> 0 committed; 1 lost a race (refusals printed) + transact && return 0 + local lost=0 ref cur p + for ref in "${PLAN_ORDER[@]}"; do + [[ "${ref}" == "${NS}/paths/"* ]] || continue + cur="$(ref_oid "${ref}")" + [[ -z "${cur}" ]] && continue + p="$(ref_path "${cur}" "${ref}")" + [[ -z "${p}" ]] && continue + describe "${cur}" + in_list "${D_JOB}" "${BATCH_JOBS[@]}" && continue + refusal "${p}" + lost=1 + done + ((lost == 0)) && transaction_refusal + return 1 +} + +claim_args() { # parses claim arguments into CA_JOB CA_HOLDER CA_TTL CA_PARENT CA_PATHS + CA_JOB='' + CA_HOLDER='' + CA_TTL="${DEFAULT_TTL}" + CA_PARENT='' + CA_PATHS=() + while (($# > 0)); do + case "$1" in + --job) + [[ $# -ge 2 ]] || usage + CA_JOB="$2" + shift 2 + ;; + --holder) + [[ $# -ge 2 ]] || usage + CA_HOLDER="$2" + shift 2 + ;; + --ttl) + [[ $# -ge 2 ]] || usage + CA_TTL="$2" + shift 2 + ;; + --parent) + [[ $# -ge 2 ]] || usage + CA_PARENT="$2" + shift 2 + ;; + --) + shift + CA_PATHS+=("$@") + break + ;; + -*) usage ;; + *) + CA_PATHS+=("$1") + shift + ;; + esac + done + [[ -n "${CA_JOB}" && -n "${CA_HOLDER}" ]] || usage + valid_job "${CA_JOB}" || fail "job id '${CA_JOB}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 + valid_ttl CA_TTL "${CA_TTL}" || fail '--ttl is a positive number of seconds' 2 + valid_holder "${CA_HOLDER}" || fail 'holder must be one line' 2 + ((${#CA_PATHS[@]} > 0)) || usage +} + +cmd_claim() { + claim_args "$@" + plan_reset + plan_claim "${CA_JOB}" "${CA_HOLDER}" "${CA_TTL}" "${CA_PARENT}" "${CA_PATHS[@]}" + ((CONFLICTS)) && exit 1 + commit_plan || exit 1 + printf '%s\n' "${CLAIM_LINE}" +} diff --git a/lib/100-batch.sh b/lib/100-batch.sh new file mode 100644 index 0000000..af3e183 --- /dev/null +++ b/lib/100-batch.sh @@ -0,0 +1,50 @@ +# ---------------------------------------------------------------- batch + +cmd_batch() { + (($# == 0)) || usage + local line key val job='' holder='' ttl='' parent='' paths=() in_paths=0 count=0 lines_out=() + plan_reset + finish_record() { + if [[ -z "${job}" && -z "${holder}" && -z "${ttl}" && -z "${parent}" && ${#paths[@]} -eq 0 ]]; then return 0; fi # only a wholly empty record is skipped; one with just parent: or ttl: is malformed + [[ -n "${job}" && -n "${holder}" && ${#paths[@]} -gt 0 ]] || fail 'batch: every record needs job:, holder: and at least one path under paths:' 2 + valid_job "${job}" || fail "batch: job id '${job}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 + [[ -z "${ttl}" ]] && ttl="${DEFAULT_TTL}" + valid_ttl ttl "${ttl}" || fail 'batch: ttl is a positive number of seconds' 2 + valid_holder "${holder}" || fail 'batch: holder must be one line' 2 + plan_claim "${job}" "${holder}" "${ttl}" "${parent}" "${paths[@]}" + lines_out+=("${CLAIM_LINE}") + count=$((count + 1)) + job='' + holder='' + ttl='' + parent='' + paths=() + in_paths=0 + } + while IFS= read -r line || [[ -n "${line}" ]]; do + if [[ -z "${line}" ]]; then + finish_record + continue + fi + if ((in_paths)); then + paths+=("${line}") + continue + fi + key="${line%%:*}" + val="${line#*:}" + val="${val# }" + case "${key}" in + job) job="${val}" ;; + holder) holder="${val}" ;; + ttl) ttl="${val}" ;; + parent) parent="${val}" ;; + paths) in_paths=1 ;; + *) fail "batch: unknown line '${line}'" 2 ;; + esac + done + finish_record + ((count > 0)) || fail 'batch: no records on stdin' 2 + ((CONFLICTS)) && exit 1 + commit_plan || exit 1 + printf '%s\n' "${lines_out[@]}" +} diff --git a/lib/110-release.sh b/lib/110-release.sh new file mode 100644 index 0000000..ac65857 --- /dev/null +++ b/lib/110-release.sh @@ -0,0 +1,93 @@ +# ---------------------------------------------------------------- release + +cmd_release() { + local jobs=() records=() acqs=() _j1 j have_acq + while (($# > 0)); do + case "$1" in + --job) + [[ $# -ge 2 ]] || usage + jobs+=("$2") + records+=('') + acqs+=('') + shift 2 + ;; + --record) + [[ $# -ge 2 ]] || usage + ((${#jobs[@]} > 0)) || usage + valid_oid "$2" || fail '--record is an object id' 2 + records[${#jobs[@]} - 1]="$2" + shift 2 + ;; + --acquisition) + [[ $# -ge 2 ]] || usage + ((${#jobs[@]} > 0)) || usage + acqs[${#jobs[@]} - 1]="$2" + shift 2 + ;; + *) usage ;; + esac + done + ((${#jobs[@]} > 0)) || usage + for j in "${jobs[@]}"; do + valid_job "${j}" || fail "job id '${j}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 + done + local attempt i present counts cascades absent superseded jref oid + for ((attempt = 0; attempt < RETRIES; attempt++)); do + snapshot + plan_reset + present=() + counts=() + cascades=() + absent=() + superseded=() + for i in "${!jobs[@]}"; do + j="${jobs[${i}]}" + jref="$(job_ref "${j}")" + oid="$(ref_oid "${jref}")" + if [[ -z "${oid}" ]]; then + absent+=("${j}") + continue + fi + if [[ -n "${records[${i}]}" && "${records[${i}]}" != "${oid}" ]]; then + superseded+=("${j}") + continue + fi + if [[ -n "${acqs[${i}]}" ]]; then + have_acq="$(field "${oid}" acquisition)" + if [[ "${have_acq}" != "${acqs[${i}]}" ]]; then + superseded+=("${j}") + continue + fi + fi + in_list "${j}" "${present[@]}" && continue + plan_terminate "${j}" || fail "${PLAN_CONFLICT}" 1 + present+=("${j}") + counts+=("${TERMINATED_PATHS}") + cascades+=("${TERMINATED_CASCADE}") + done + if ((${#PLAN_ORDER[@]} == 0)); then break; fi + transact && break + sleep 0.01 + done + ((attempt < RETRIES)) || { + transaction_refusal + exit 1 + } + for i in "${!present[@]}"; do + json_str _j1 "${present[${i}]}" + if [[ "${cascades[${i}]}" == '[]' ]]; then + printf '{"event":"released","job":%s,"paths":%d}\n' "${_j1}" "${counts[${i}]}" + else + printf '{"event":"released","job":%s,"paths":%d,"cascaded":%s}\n' "${_j1}" "${counts[${i}]}" "${cascades[${i}]}" + fi + done + for j in "${superseded[@]}"; do + json_str _j1 "${j}" + printf '{"event":"nothing","job":%s,"reason":"superseded"}\n' "${_j1}" + done + for j in "${absent[@]}"; do + json_str _j1 "${j}" + printf '{"event":"nothing","job":%s}\n' "${_j1}" + done + return 0 +} diff --git a/lib/120-check.sh b/lib/120-check.sh new file mode 100644 index 0000000..f889da8 --- /dev/null +++ b/lib/120-check.sh @@ -0,0 +1,28 @@ +# ---------------------------------------------------------------- check + +cmd_check() { + ensure_snapshot # in this shell, so the $(…) reads below inherit one fresh snapshot instead of each taking their own + (($# > 0)) || usage + local at held=0 p n ref cur jp _j1 _j2 + now_v at + for p in "$@"; do + n="$(normalize_path "${p}")" || exit 2 + path_ref ref "${n}" + cur="$(ref_oid "${ref}")" + json_str jp "${n}" + if [[ -z "${cur}" ]]; then + printf '{"path":%s,"state":"free"}\n' "${jp}" + continue + fi + describe "${cur}" + json_str _j1 "${D_HOLDER}" + json_str _j2 "${D_JOB}" + if [[ "${D_EXPIRES}" -gt "${at}" ]]; then + printf '{"path":%s,"state":"held","holder":%s,"job":%s,"expires":%s,"remaining":%s}\n' "${jp}" "${_j1}" "${_j2}" "${D_EXPIRES}" "${D_REMAINING}" + held=1 + else + printf '{"path":%s,"state":"expired","holder":%s,"job":%s,"expires":%s,"remaining":0}\n' "${jp}" "${_j1}" "${_j2}" "${D_EXPIRES}" + fi + done + return "${held}" +} diff --git a/lib/130-list-show-ttl.sh b/lib/130-list-show-ttl.sh new file mode 100644 index 0000000..5e0e331 --- /dev/null +++ b/lib/130-list-show-ttl.sh @@ -0,0 +1,80 @@ +# ---------------------------------------------------------------- list / show / ttl + +lock_line() { # oid -> one JSON line for list and show; no fork per line, so a list of n locks is O(n) bash and no processes + ensure_snapshot # in this shell, so the record parses below memoise here + local _j1 _j2 _j3 _j4 jpaths claimed pj paths acq + describe "$1" + field_v claimed "$1" claimed + field_v acq "$1" acquisition + json_str _j4 "${acq}" + record_paths_v paths "$1" + json_paths_v jpaths "${paths}" + json_str _j1 "${D_JOB}" + json_str _j2 "${D_HOLDER}" + json_str _j3 "$1" + parent_json pj "$1" + printf '{"job":%s,"holder":%s,"state":"%s","claimed":%s,"expires":%s,"remaining":%s%s,"paths":%s,"record":%s,"acquisition":%s}\n' \ + "${_j1}" "${_j2}" "${D_STATE}" "${claimed:-0}" "${D_EXPIRES}" "${D_REMAINING}" "${pj}" "${jpaths}" "${_j3}" "${_j4}" +} + +cmd_list() { + (($# == 0)) || usage + local ref oid rows + rows="$(job_refs)" + while IFS=' ' read -r ref oid; do + [[ -z "${ref}" ]] && continue + lock_line "${oid}" + done <<<"${rows}" + return 0 +} + +job_arg() { # --job [--ttl ] -> JOB_ARG TTL_ARG, or usage + JOB_ARG='' + TTL_ARG='' + while (($# > 0)); do + case "$1" in + --job) + [[ $# -ge 2 ]] || usage + JOB_ARG="$2" + shift 2 + ;; + --ttl) + [[ $# -ge 2 ]] || usage + TTL_ARG="$2" + shift 2 + ;; + *) usage ;; + esac + done + [[ -n "${JOB_ARG}" ]] || usage + valid_job "${JOB_ARG}" || fail "job id '${JOB_ARG}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 +} + +missing() { # job -> one line on stderr, exit 1 + local _j1 + json_str _j1 "$1" + printf '{"event":"missing","job":%s}\n' "${_j1}" >&2 + exit 1 +} + +cmd_show() { + ensure_snapshot # in this shell, so the $(…) reads below inherit one fresh snapshot instead of each taking their own + local jref oid + job_arg "$@" + jref="$(job_ref "${JOB_ARG}")" + oid="$(ref_oid "${jref}")" + [[ -n "${oid}" ]] || missing "${JOB_ARG}" + lock_line "${oid}" +} + +cmd_ttl() { + ensure_snapshot # in this shell, so the $(…) reads below inherit one fresh snapshot instead of each taking their own + local _j1 jref oid + job_arg "$@" + jref="$(job_ref "${JOB_ARG}")" + oid="$(ref_oid "${jref}")" + [[ -n "${oid}" ]] || missing "${JOB_ARG}" + describe "${oid}" + json_str _j1 "${D_JOB}" + printf '{"job":%s,"expires":%s,"remaining":%s}\n' "${_j1}" "${D_EXPIRES}" "${D_REMAINING}" +} diff --git a/lib/140-extend.sh b/lib/140-extend.sh new file mode 100644 index 0000000..c5ae15c --- /dev/null +++ b/lib/140-extend.sh @@ -0,0 +1,39 @@ +# ---------------------------------------------------------------- extend + +cmd_extend() { + local _j1 oid jref at expires record new_oid paths p ref have claimed parent family attempt acq ttl + job_arg "$@" + valid_ttl ttl "${TTL_ARG}" || fail '--ttl is a positive number of seconds' 2 + jref="$(job_ref "${JOB_ARG}")" + for ((attempt = 0; attempt < RETRIES; attempt++)); do + snapshot + plan_reset + oid="$(ref_oid "${jref}")" + [[ -n "${oid}" ]] || missing "${JOB_ARG}" + describe "${oid}" + now_v at + expires=$((at + ttl)) + paths="$(record_paths "${oid}")" + claimed="$(field "${oid}" claimed)" + parent="$(field "${oid}" parent)" + family="$(field "${oid}" family)" + acq="$(field "${oid}" acquisition)" + record_text record "${D_JOB}" "${D_HOLDER}" "${claimed}" "${expires}" "${parent}" "${family:-0}" "${acq}" "${paths}" + write_blob new_oid "${record}" || fail 'could not write the lock record' + plan_set "${jref}" "${oid}" "${new_oid}" || fail "${PLAN_CONFLICT}" 1 + while IFS= read -r p; do + [[ -z "${p}" ]] && continue + path_ref ref "${p}" + have="$(ref_oid "${ref}")" + [[ "${have}" == "${oid}" ]] && { plan_set "${ref}" "${oid}" "${new_oid}" || fail "${PLAN_CONFLICT}" 1; } + done <<<"${paths}" + transact && break + sleep 0.01 + done + ((attempt < RETRIES)) || { + transaction_refusal + exit 1 + } + json_str _j1 "${JOB_ARG}" + printf '{"event":"extended","job":%s,"expires":%s}\n' "${_j1}" "${expires}" +} diff --git a/lib/150-sweep.sh b/lib/150-sweep.sh new file mode 100644 index 0000000..e7c6e6d --- /dev/null +++ b/lib/150-sweep.sh @@ -0,0 +1,43 @@ +# ---------------------------------------------------------------- sweep + +cmd_sweep() { + (($# == 0)) || usage + local at ref oid rows rjob rholder rexpires _j1 _j2 attempt done_jobs=() still + now_v at + rows="$(job_refs)" + while IFS=' ' read -r ref oid; do + [[ -z "${ref}" ]] && continue + describe "${oid}" + [[ "${D_EXPIRES}" -gt "${at}" ]] && continue + rjob="${D_JOB}" + in_list "${rjob}" "${done_jobs[@]}" && continue # already swept as someone's descendant + rholder="${D_HOLDER}" + rexpires="${D_EXPIRES}" + local swept=0 + for ((attempt = 0; attempt < RETRIES; attempt++)); do + snapshot + plan_reset + still="$(ref_oid "${ref}")" + [[ -n "${still}" ]] || break # gone meanwhile + [[ "${still}" == "${oid}" ]] || break # replaced or extended meanwhile: that is not the lock we saw expire + plan_terminate "${rjob}" || fail "${PLAN_CONFLICT}" 1 + if transact; then + swept=1 + break + fi + sleep 0.01 + done + ((swept)) || continue + done_jobs+=("${rjob}") + local d + for d in "${DESC[@]}"; do done_jobs+=("${d}"); done + json_str _j1 "${rjob}" + json_str _j2 "${rholder}" + if [[ "${TERMINATED_CASCADE}" == '[]' ]]; then + printf '{"event":"swept","job":%s,"holder":%s,"expires":%s}\n' "${_j1}" "${_j2}" "${rexpires}" + else + printf '{"event":"swept","job":%s,"holder":%s,"expires":%s,"cascaded":%s}\n' "${_j1}" "${_j2}" "${rexpires}" "${TERMINATED_CASCADE}" + fi + done <<<"${rows}" + return 0 +} diff --git a/lib/160-with.sh b/lib/160-with.sh new file mode 100644 index 0000000..6f16cb7 --- /dev/null +++ b/lib/160-with.sh @@ -0,0 +1,155 @@ +# ---------------------------------------------------------------- with + +acquire_with_wait() { # kind(lock|sem) wait-seconds errfile -> 0 acquired (ACQUIRED_LINE set), else exits with the refusal + local kind="$1" wait="$2" errfile="$3" out rc clock deadline + clock="$(date +%s)" # the wait window is wall-clock time, whatever GIT_LOCKS_NOW says about lock expiry + deadline=$((clock + wait)) + while :; do + SNAP_LOADED=0 # each attempt reads afresh; a subshell cannot invalidate for us + if [[ "${kind}" == sem ]]; then + out="$( (sem_acquire_once "${W_SEM}" "${W_JOB}" "${W_HOLDER}" "${W_TTL}") 2>"${errfile}")" + elif [[ -n "${W_PARENT}" ]]; then + out="$( (cmd_claim --job "${W_JOB}" --holder "${W_HOLDER}" --ttl "${W_TTL}" --parent "${W_PARENT}" -- "${W_PATHS[@]}") 2>"${errfile}")" + else + out="$( (cmd_claim --job "${W_JOB}" --holder "${W_HOLDER}" --ttl "${W_TTL}" -- "${W_PATHS[@]}") 2>"${errfile}")" + fi + rc=$? + if ((rc == 0)); then + ACQUIRED_LINE="${out}" + return 0 + fi + clock="$(date +%s)" + if ((rc != 1 || clock >= deadline)); then + cat "${errfile}" >&2 + return "${rc}" + fi + sleep 1 + done +} + +record_of() { # VAR json-line -> the "acquisition" field, the identity that survives renewals + local line="$2" rec='' + [[ "${line}" =~ \"acquisition\":\"([^\"]+)\" ]] && rec="${BASH_REMATCH[1]}" + printf -v "$1" '%s' "${rec}" +} + +cmd_with() { + W_JOB='' + W_HOLDER='' + W_TTL="${DEFAULT_TTL}" + W_PARENT='' + W_SEM='' + W_PATHS=() + local wait=0 command=() seen_dashdash=0 a + while (($# > 0)); do + a="$1" + if ((seen_dashdash)); then + command+=("${a}") + shift + continue + fi + case "${a}" in + --job) + [[ $# -ge 2 ]] || usage + W_JOB="$2" + shift 2 + ;; + --holder) + [[ $# -ge 2 ]] || usage + W_HOLDER="$2" + shift 2 + ;; + --ttl) + [[ $# -ge 2 ]] || usage + W_TTL="$2" + shift 2 + ;; + --wait) + [[ $# -ge 2 ]] || usage + wait="$2" + shift 2 + ;; + --parent) + [[ $# -ge 2 ]] || usage + W_PARENT="$2" + shift 2 + ;; + --sem) + [[ $# -ge 2 ]] || usage + W_SEM="$2" + shift 2 + ;; + --) + seen_dashdash=1 + shift + ;; + -*) usage ;; + *) + W_PATHS+=("${a}") + shift + ;; + esac + done + [[ -n "${W_JOB}" && -n "${W_HOLDER}" ]] || usage + ((${#command[@]} > 0)) || usage + [[ -n "${W_SEM}" || ${#W_PATHS[@]} -gt 0 ]] || usage + [[ "${wait}" =~ ^[0-9]+$ ]] || fail '--wait is a number of seconds' 2 + # Validate everything before acquiring anything: the semaphore path does not pass through claim_args or cmd_sem. + valid_job "${W_JOB}" || fail "job id '${W_JOB}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 + valid_holder "${W_HOLDER}" || fail 'holder must be one line' 2 + valid_ttl W_TTL "${W_TTL}" || fail '--ttl is a positive number of seconds' 2 + [[ -z "${W_SEM}" ]] || valid_job "${W_SEM}" || fail "semaphore name '${W_SEM}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 + [[ -z "${W_PARENT}" ]] || valid_job "${W_PARENT}" || fail "parent id '${W_PARENT}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 + + local errfile sem_record='' lock_record='' rc + errfile="$(mktemp "${TMPDIR:-/tmp}/git-locks-with.XXXXXX")" || fail 'cannot create a temporary file' + + # Release exactly the acquisitions this invocation made, never whatever wears the job name now. Armed before the + # first acquisition: a signal while waiting for the lock must give back the slot already taken. + local status=0 + with_release_all() { + if [[ -n "${lock_record}" ]]; then + SNAP_LOADED=0 + (cmd_release --job "${W_JOB}" --acquisition "${lock_record}") >&2 + fi + if [[ -n "${sem_record}" ]]; then + with_release_sem "${sem_record}" + fi + rm -f "${errfile}" + return 0 + } + trap 'with_release_all; exit 130' INT + trap 'with_release_all; exit 143' TERM + if [[ -n "${W_SEM}" ]]; then + acquire_with_wait sem "${wait}" "${errfile}" + rc=$? + ((rc == 0)) || { + rm -f "${errfile}" + exit "${rc}" + } + record_of sem_record "${ACQUIRED_LINE}" + printf '%s\n' "${ACQUIRED_LINE}" >&2 + fi + if ((${#W_PATHS[@]} > 0)); then + acquire_with_wait lock "${wait}" "${errfile}" + rc=$? + ((rc == 0)) || { + rm -f "${errfile}" + [[ -n "${sem_record}" ]] && with_release_sem "${sem_record}" + exit "${rc}" + } + record_of lock_record "${ACQUIRED_LINE}" + printf '%s\n' "${ACQUIRED_LINE}" >&2 + fi + rm -f "${errfile}" + + "${command[@]}" || status=$? + trap - INT TERM + with_release_all + return "${status}" +} + +with_release_sem() { # record -> releases this invocation's slot, if it is still the current one + SNAP_LOADED=0 + (sem_release_once "${W_SEM}" "${W_JOB}" "$1") >&2 +} diff --git a/lib/170-semaphores.sh b/lib/170-semaphores.sh new file mode 100644 index 0000000..5242a6d --- /dev/null +++ b/lib/170-semaphores.sh @@ -0,0 +1,365 @@ +# ---------------------------------------------------------------- semaphores +# +# refs/locks/sem//meta holds the capacity; slots/ one record per +# holder; gen a token every transaction on the semaphore rewrites, so two +# acquirers who both counted "n of N live" contend on one compare-and-swap and +# exactly one commits. The other re-reads. + +sem_meta_ref() { printf '%s/sem/%s/meta' "${NS}" "$1"; } +sem_gen_ref() { printf '%s/sem/%s/gen' "${NS}" "$1"; } +sem_slot_ref() { printf '%s/sem/%s/slots/%s' "${NS}" "$1" "$2"; } + +sem_missing() { # name -> stderr line, exit 1 + local _j1 + json_str _j1 "$1" + printf '{"event":"missing","semaphore":%s}\n' "${_j1}" >&2 + exit 1 +} + +sem_refusal() { # name reason [capacity live] + local _j1 + json_str _j1 "$1" + case "$2" in + capacity) printf '{"event":"refused","reason":"capacity","semaphore":%s,"capacity":%s,"live":%s}\n' "${_j1}" "$3" "$4" >&2 ;; + exists) printf '{"event":"refused","reason":"exists","semaphore":%s}\n' "${_j1}" >&2 ;; + live) printf '{"event":"refused","reason":"live","semaphore":%s,"live":%s}\n' "${_j1}" "$4" >&2 ;; + *) transaction_refusal ;; + esac +} + +gen_blob() { # VAR: a fresh generation token as a blob + local at content + now_v at + content="$(printf 'generation %s %s %s' "${at}" "$$" "${RANDOM}${RANDOM}")" + write_blob "$1" "${content}" +} + +# Reads a semaphore into: SEM_CAP, SEM_META_OID, SEM_GEN_OID, SEM_LIVE, and parallel arrays +# SLOT_JOBS SLOT_OIDS SLOT_LIVE (1/0) SLOT_HOLDER SLOT_CLAIMED SLOT_EXPIRES SLOT_REMAINING. +sem_read() { # name -> 0, or 1 when the semaphore does not exist + ensure_snapshot # in this shell, so the $(…) reads below inherit one fresh snapshot instead of each taking their own + local name="$1" mref gref rows ref oid at exp claimed + mref="$(sem_meta_ref "${name}")" + SEM_META_OID="$(ref_oid "${mref}")" + [[ -n "${SEM_META_OID}" ]] || return 1 + SEM_CAP="$(field "${SEM_META_OID}" capacity)" + gref="$(sem_gen_ref "${name}")" + SEM_GEN_OID="$(ref_oid "${gref}")" + SLOT_JOBS=() + SLOT_OIDS=() + SLOT_LIVE=() + SLOT_HOLDER=() + SLOT_CLAIMED=() + SLOT_EXPIRES=() + SLOT_REMAINING=() + SEM_LIVE=0 + now_v at + rows="$(refs_under "${NS}/sem/${name}/slots/")" + while IFS=' ' read -r ref oid; do + [[ -z "${ref}" ]] && continue + SLOT_JOBS+=("$(field "${oid}" job)") + SLOT_OIDS+=("${oid}") + SLOT_HOLDER+=("$(field "${oid}" holder)") + claimed="$(field "${oid}" claimed)" + SLOT_CLAIMED+=("${claimed:-0}") + exp="$(field "${oid}" expires)" + exp="${exp:-0}" + SLOT_EXPIRES+=("${exp}") + if ((exp > at)); then + SLOT_LIVE+=(1) + SLOT_REMAINING+=("$((exp - at))") + SEM_LIVE=$((SEM_LIVE + 1)) + else + SLOT_LIVE+=(0) + SLOT_REMAINING+=(0) + fi + done <<<"${rows}" + return 0 +} + +sem_plan_evict_expired() { # name [keep-job] -> plans deletes for expired slots, except keep-job's + local i sref + for i in "${!SLOT_JOBS[@]}"; do + ((SLOT_LIVE[i])) && continue + [[ "${SLOT_JOBS[${i}]}" == "${2:-}" ]] && continue + sref="$(sem_slot_ref "$1" "${SLOT_JOBS[${i}]}")" + plan_set "${sref}" "${SLOT_OIDS[${i}]}" '' || return 1 + done +} + +sem_transact() { # name -> plans the generation CAS and the meta verify, then commits; 0 ok, 2 lost the race + local gref newgen mref + gref="$(sem_gen_ref "$1")" + mref="$(sem_meta_ref "$1")" + gen_blob newgen || fail 'could not write the generation token' + plan_set "${gref}" "${SEM_GEN_OID}" "${newgen}" || fail "${PLAN_CONFLICT}" 1 + plan_set "${mref}" "${SEM_META_OID}" '=' || fail "${PLAN_CONFLICT}" 1 + transact && return 0 + return 2 +} + +sem_show_line() { # name, after sem_read -> one JSON line or the text block + local i _j1 _j2 _j3 _j4 _j5 items=() IFS acq + for i in "${!SLOT_JOBS[@]}"; do + ((SLOT_LIVE[i])) || continue + json_str _j2 "${SLOT_JOBS[${i}]}" + json_str _j3 "${SLOT_HOLDER[${i}]}" + json_str _j4 "${SLOT_OIDS[${i}]}" + acq="$(field "${SLOT_OIDS[${i}]}" acquisition)" + json_str _j5 "${acq}" + items+=("{\"job\":${_j2},\"holder\":${_j3},\"claimed\":${SLOT_CLAIMED[${i}]},\"expires\":${SLOT_EXPIRES[${i}]},\"remaining\":${SLOT_REMAINING[${i}]},\"record\":${_j4},\"acquisition\":${_j5}}") + done + json_str _j1 "$1" + IFS=',' + printf '{"semaphore":%s,"capacity":%s,"live":%s,"slots":[%s]}\n' "${_j1}" "${SEM_CAP}" "${SEM_LIVE}" "${items[*]}" +} + +sem_acquire_once() { # name job holder ttl -> 0 acquired (line printed), 1 refused, 2 usage/missing + local attempt rc + for ((attempt = 0; attempt < RETRIES; attempt++)); do + snapshot + sem_acquire_attempt "$1" "$2" "$3" "$4" + rc=$? + ((rc == 2)) || return "${rc}" + sleep 0.01 + done + transaction_refusal + return 1 +} + +sem_acquire_attempt() { # one read-plan-transact; 0 acquired, 1 refused (capacity), 2 lost the race + ensure_snapshot # in this shell, so the $(…) reads below inherit one fresh snapshot instead of each taking their own + local name="$1" job="$2" holder="$3" ttl="$4" i at expires record oid _j1 _j2 _j3 _j4 _j5 own_oid='' own_live=0 slot_ref live_after acq='' + sem_read "${name}" || sem_missing "${name}" + for i in "${!SLOT_JOBS[@]}"; do + if [[ "${SLOT_JOBS[${i}]}" == "${job}" ]]; then + own_oid="${SLOT_OIDS[${i}]}" + own_live="${SLOT_LIVE[${i}]}" + ((own_live)) && acq="$(field "${own_oid}" acquisition)" # a refresh keeps the acquisition; a re-acquire after expiry mints one + fi + done + [[ -n "${acq}" ]] || new_acquisition acq + if ((own_live == 0 && SEM_LIVE >= SEM_CAP)); then + sem_refusal "${name}" capacity "${SEM_CAP}" "${SEM_LIVE}" + return 1 + fi + now_v at + expires=$((at + ttl)) + record="$(printf 'schema: %s\nsemaphore: %s\njob: %s\nholder: %s\nclaimed: %s\nexpires: %s\nacquisition: %s' "${SLOT_SCHEMA}" "${name}" "${job}" "${holder}" "${at}" "${expires}" "${acq}")" + write_blob oid "${record}" || fail 'could not write the slot record' + plan_reset + sem_plan_evict_expired "${name}" "${job}" || fail "${PLAN_CONFLICT}" 1 + slot_ref="$(sem_slot_ref "${name}" "${job}")" + if [[ -n "${own_oid}" ]]; then + plan_set "${slot_ref}" "${own_oid}" "${oid}" || fail "${PLAN_CONFLICT}" 1 # live or expired: one transition, old to new + if ((own_live)); then live_after="${SEM_LIVE}"; else live_after=$((SEM_LIVE + 1)); fi + else + plan_set "${slot_ref}" '' "${oid}" || fail "${PLAN_CONFLICT}" 1 + live_after=$((SEM_LIVE + 1)) + fi + sem_transact "${name}" || return 2 + json_str _j1 "${name}" + json_str _j2 "${job}" + json_str _j3 "${holder}" + json_str _j4 "${oid}" + json_str _j5 "${acq}" + printf '{"event":"acquired","semaphore":%s,"job":%s,"holder":%s,"claimed":%s,"expires":%s,"live":%s,"capacity":%s,"record":%s,"acquisition":%s}\n' \ + "${_j1}" "${_j2}" "${_j3}" "${at}" "${expires}" "${live_after}" "${SEM_CAP}" "${_j4}" "${_j5}" + return 0 +} + +sem_release_once() { # name job [record] -> 0 released or nothing to release; 1 only when the race never settles + local attempt rc + for ((attempt = 0; attempt < RETRIES; attempt++)); do + snapshot + sem_release_attempt "$1" "$2" "${3:-}" + rc=$? + ((rc == 2)) || return "${rc}" + sleep 0.01 + done + transaction_refusal + return 1 +} + +sem_release_attempt() { # one read-plan-transact; 0 done, 2 lost the race + ensure_snapshot # in this shell, so the $(…) reads below inherit one fresh snapshot instead of each taking their own + local name="$1" job="$2" want="$3" i own_oid='' own_live=0 _j1 _j2 live_after slot_ref + sem_read "${name}" || sem_missing "${name}" + for i in "${!SLOT_JOBS[@]}"; do + if [[ "${SLOT_JOBS[${i}]}" == "${job}" ]]; then + own_oid="${SLOT_OIDS[${i}]}" + own_live="${SLOT_LIVE[${i}]}" + fi + done + json_str _j1 "${name}" + json_str _j2 "${job}" + if [[ -z "${own_oid}" ]]; then + printf '{"event":"nothing","semaphore":%s,"job":%s}\n' "${_j1}" "${_j2}" + return 0 + fi + local own_acq + own_acq="$(field "${own_oid}" acquisition)" + if [[ -n "${want}" && "${want}" != "${own_oid}" && "${want}" != "${own_acq}" ]]; then + printf '{"event":"nothing","semaphore":%s,"job":%s,"reason":"superseded"}\n' "${_j1}" "${_j2}" + return 0 + fi + if ((own_live)); then live_after=$((SEM_LIVE - 1)); else live_after="${SEM_LIVE}"; fi + plan_reset + sem_plan_evict_expired "${name}" "${job}" || fail "${PLAN_CONFLICT}" 1 + slot_ref="$(sem_slot_ref "${name}" "${job}")" + plan_set "${slot_ref}" "${own_oid}" '' || fail "${PLAN_CONFLICT}" 1 + sem_transact "${name}" || return 2 + printf '{"event":"released","semaphore":%s,"job":%s,"live":%s,"capacity":%s}\n' "${_j1}" "${_j2}" "${live_after}" "${SEM_CAP}" +} + +cmd_sem() { + (($# > 0)) || usage + local verb="$1" name='' job='' holder='' ttl="${DEFAULT_TTL}" wait=0 capacity='' record='' _j1 + shift + case "${verb}" in + list) + (($# == 0)) || usage + local rows ref oid + rows="$(refs_under "${NS}/sem/")" + while IFS=' ' read -r ref oid; do + [[ "${ref}" == */meta ]] || continue + name="${ref#"${NS}"/sem/}" + name="${name%/meta}" + sem_read "${name}" || continue + sem_show_line "${name}" + done <<<"${rows}" + return 0 + ;; + create | acquire | release | show | delete) ;; + *) usage ;; + esac + (($# > 0)) || usage + name="$1" + shift + valid_job "${name}" || fail "semaphore name '${name}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 + while (($# > 0)); do + case "$1" in + --job) + [[ $# -ge 2 ]] || usage + job="$2" + shift 2 + ;; + --holder) + [[ $# -ge 2 ]] || usage + holder="$2" + shift 2 + ;; + --ttl) + [[ $# -ge 2 ]] || usage + ttl="$2" + shift 2 + ;; + --wait) + [[ $# -ge 2 ]] || usage + wait="$2" + shift 2 + ;; + --capacity) + [[ $# -ge 2 ]] || usage + capacity="$2" + shift 2 + ;; + --record) + [[ $# -ge 2 ]] || usage + valid_oid "$2" || fail '--record is an object id' 2 + record="$2" + shift 2 + ;; + --acquisition) + [[ $# -ge 2 ]] || usage + record="$2" + shift 2 + ;; + *) usage ;; + esac + done + case "${verb}" in + create) + [[ "${capacity}" =~ ^[0-9]+$ && "${capacity}" -gt 0 ]] || fail '--capacity is a positive number' 2 + if sem_read "${name}"; then + sem_refusal "${name}" exists + exit 1 + fi + local at meta gen mref gref content + now_v at + content="$(printf 'schema: %s\nsemaphore: %s\ncapacity: %s\ncreated: %s' "${SEM_SCHEMA}" "${name}" "${capacity}" "${at}")" + write_blob meta "${content}" || fail 'could not write the semaphore record' + gen_blob gen || fail 'could not write the generation token' + mref="$(sem_meta_ref "${name}")" + gref="$(sem_gen_ref "${name}")" + plan_reset + plan_set "${mref}" '' "${meta}" + plan_set "${gref}" '' "${gen}" + transact || { + sem_refusal "${name}" exists + exit 1 + } + json_str _j1 "${name}" + printf '{"event":"created","semaphore":%s,"capacity":%s}\n' "${_j1}" "${capacity}" + ;; + acquire) + [[ -n "${job}" && -n "${holder}" ]] || usage + valid_job "${job}" || fail "job id '${job}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 + valid_ttl ttl "${ttl}" || fail '--ttl is a positive number of seconds' 2 + valid_holder "${holder}" || fail 'holder must be one line' 2 + [[ "${wait}" =~ ^[0-9]+$ ]] || fail '--wait is a number of seconds' 2 + W_SEM="${name}" + W_JOB="${job}" + W_HOLDER="${holder}" + W_TTL="${ttl}" + local errfile rc + errfile="$(mktemp "${TMPDIR:-/tmp}/git-locks-sem.XXXXXX")" || fail 'cannot create a temporary file' + acquire_with_wait sem "${wait}" "${errfile}" + rc=$? + rm -f "${errfile}" + ((rc == 0)) || exit "${rc}" + printf '%s\n' "${ACQUIRED_LINE}" + ;; + release) + [[ -n "${job}" ]] || usage + valid_job "${job}" || fail "job id '${job}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 + sem_release_once "${name}" "${job}" "${record}" || exit 1 + ;; + show) + sem_read "${name}" || sem_missing "${name}" + sem_show_line "${name}" + ;; + delete) + local attempt deleted=0 i mref gref sref + for ((attempt = 0; attempt < RETRIES; attempt++)); do + snapshot + sem_read "${name}" || sem_missing "${name}" + if ((SEM_LIVE > 0)); then + sem_refusal "${name}" live "${SEM_CAP}" "${SEM_LIVE}" + exit 1 + fi + plan_reset + mref="$(sem_meta_ref "${name}")" + gref="$(sem_gen_ref "${name}")" + plan_set "${mref}" "${SEM_META_OID}" '' + plan_set "${gref}" "${SEM_GEN_OID}" '' + for i in "${!SLOT_JOBS[@]}"; do + sref="$(sem_slot_ref "${name}" "${SLOT_JOBS[${i}]}")" + plan_set "${sref}" "${SLOT_OIDS[${i}]}" '' + done + if transact; then + deleted=1 + break + fi + sleep 0.01 + done + ((deleted)) || { + transaction_refusal + exit 1 + } + json_str _j1 "${name}" + printf '{"event":"deleted","semaphore":%s}\n' "${_j1}" + ;; + *) usage ;; + esac +} diff --git a/lib/180-schema-marker.sh b/lib/180-schema-marker.sh new file mode 100644 index 0000000..e94a12f --- /dev/null +++ b/lib/180-schema-marker.sh @@ -0,0 +1 @@ +# ---------------------------------------------------------------- schema, store, main diff --git a/lib/990-main.sh b/lib/990-main.sh new file mode 100644 index 0000000..cd99159 --- /dev/null +++ b/lib/990-main.sh @@ -0,0 +1,44 @@ +# ---------------------------------------------------------------- store, main + +cmd_store() { + local _j1 + (($# == 0)) || usage + json_str _j1 "${STORE}" + printf '{"store":%s}\n' "${_j1}" +} + +main() { + (($# > 0)) || usage + local cmd="$1" a line + shift + case "${cmd}" in + help | --help | -h) + usage_json line + printf '%s\n' "${line}" + exit 0 + ;; + schema) + cmd_schema "$@" + exit 0 + ;; + version | --version) + (($# == 0)) || usage + printf '{"name":"git-locks","version":"%s"}\n' "${VERSION}" + exit 0 + ;; + claim | batch | release | check | list | sweep | store | show | ttl | extend | with | sem) ;; + *) usage ;; + esac + for a in "$@"; do + [[ "${a}" == '--' ]] && break # what follows belongs to the wrapped command + if [[ "${a}" == '--help' || "${a}" == '-h' ]]; then + sub_usage "${cmd}" + exit 0 + fi + done + resolve_store + case "${cmd}" in store) ;; *) ensure_snapshot ;; esac # once, in this shell: subshells inherit it instead of re-reading + "cmd_${cmd}" "$@" +} + +main "$@" diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..cd1f45b --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Assemble bin/git-locks from lib/*.sh in lexical order, generating the schema module from +# schema/git-locks.schema.json (minified to one line) between the modules numbered below 95 +# and 99-main.sh. `make build` writes bin/git-locks; `scripts/build.sh ` writes elsewhere, +# which is how the test suite checks that the committed script is exactly what lib/ builds. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +out="${1:-${here}/bin/git-locks}" +schema="$(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1])), separators=(",", ":"), ensure_ascii=False))' "${here}/schema/git-locks.schema.json")" +tmp="$(mktemp "${TMPDIR:-/tmp}/git-locks-build.XXXXXX")" +{ + for f in "${here}"/lib/[0-8][0-9][0-9]-*.sh "${here}"/lib/9[0-8][0-9]-*.sh; do + [[ -e "${f}" ]] && cat "${f}" + done + printf 'cmd_schema() { # the public output schema, one JSON line; the pretty form is schema/git-locks.schema.json in the repository\n' + printf ' (($# == 0)) || usage\n' + printf " cat <<'EOF'\n%s\nEOF\n}\n\n" "${schema}" + cat "${here}/lib/990-main.sh" +} >"${tmp}" +chmod 0755 "${tmp}" +mv "${tmp}" "${out}" diff --git a/test/test.sh b/test/test.sh index 4e52ea7..9522d65 100755 --- a/test/test.sh +++ b/test/test.sh @@ -1026,6 +1026,130 @@ check "with took exactly two reads before running its command (the release after git-locks check p3.md >/dev/null 2>&1 check "with released its lock afterwards" "$?" "0" +# ---------------------------------------------------------------- #11: bin/git-locks is built from lib/, byte for byte + +BUILT="$(mktemp "${TMPDIR:-/tmp}/git-locks-built.XXXXXX")" +(cd "${HERE}/.." && bash scripts/build.sh "${BUILT}") >/dev/null 2>&1 +check "scripts/build.sh assembles the script from lib/ and the schema" "$?" "0" +cmp -s "${BUILT}" "${HERE}/../bin/git-locks" +check "the committed bin/git-locks is exactly what lib/ builds (run make build after editing lib/)" "$?" "0" +libs=("${HERE}/../lib/"*.sh) +check "lib/ has more than one module" "$((${#libs[@]} > 1))" "1" + +# ---------------------------------------------------------------- #24: records are parsed once; list cost is printed, not gated + +R="$(mkrepo)" +cd "${R}" || exit 2 +for i in $(seq 1 200); do git-locks claim --job "l${i}" --holder h "f${i}.md" >/dev/null 2>&1; done +t0="$(date +%s)" +out="$(git-locks list 2>&1)" +t1="$(date +%s)" +lines n "${out}" +check "list renders all 200 locks" "${n}" "200" +printf ' info list of 200 locks took %ds (printed for the record; #24 tracks it, no gate)\n' "$((t1 - t0))" +TRACE5="$(mktemp "${TMPDIR:-/tmp}/git-locks-trace5.XXXXXX")" +GIT_LOCKS_TRACE="${TRACE5}" git-locks list >/dev/null 2>&1 +parses="$(grep -c '^parse' "${TRACE5}")" +check "each record is parsed exactly once for a list (one parse line per blob in the trace)" "${parses}" "200" + +# ---------------------------------------------------------------- review of 0.4.0: ttl is decimal, a holder is one line stored whole, sweep never deletes a renewed lock + +R="$(mkrepo)" +cd "${R}" || exit 2 +out="$(GIT_LOCKS_NOW=1000 git-locks claim --job oct --holder h --ttl 010 x.md 2>&1)" +check "claim --ttl 010 is ten seconds, not octal eight" "$?" "0" +out="$(GIT_LOCKS_NOW=1000 git-locks ttl --job oct 2>&1)" +jfields "and the lock expires at now + 10" "${out}" 'expires=1010' +GIT_LOCKS_NOW=1000 git-locks extend --job oct --ttl 020 >/dev/null 2>&1 +out="$(GIT_LOCKS_NOW=1000 git-locks ttl --job oct 2>&1)" +jfields "extend --ttl 020 is twenty seconds" "${out}" 'expires=1020' +out="$(GIT_LOCKS_NOW=1000 git-locks extend --job oct --ttl 08 2>&1)" +check "extend --ttl 08 is eight seconds, not an arithmetic error" "$?" "0" +out="$(GIT_LOCKS_NOW=1000 git-locks ttl --job oct 2>&1)" +jfields "and expires at now + 8" "${out}" 'expires=1008' +git-locks sem create o --capacity 1 >/dev/null 2>&1 +out="$(GIT_LOCKS_NOW=1000 git-locks sem acquire o --job s --holder h --ttl 010 2>&1)" +check "sem acquire --ttl 010 is accepted" "$?" "0" +out="$(GIT_LOCKS_NOW=1000 git-locks sem show o 2>&1)" +contains "and the slot has ten seconds" "${out}" '"remaining":10' +out="$(printf 'job: b\nholder: h\nttl: 010\npaths:\nb.md\n' | GIT_LOCKS_NOW=1000 git-locks batch 2>&1)" +check "batch ttl: 010 is accepted" "$?" "0" +out="$(GIT_LOCKS_NOW=1000 git-locks ttl --job b 2>&1)" +jfields "and is ten seconds" "${out}" 'expires=1010' + +out="$(printf 'parent: oct\n' | git-locks batch 2>&1)" +check "a batch record with only parent: is malformed, not silently dropped" "$?" "2" +out="$(printf 'parent: oct\n\njob: k\nholder: h\npaths:\nk.md\n' | git-locks batch 2>&1)" +check "and cannot leak its parent into the next record" "$?" "2" +git-locks show --job k >/dev/null 2>&1 +check "so no lock k was made" "$?" "1" + +out="$(GIT_LOCKS_NOW=1000 git-locks claim --job ctl --holder $'a\x1eexpires: 5\x1fjob\x1e' --ttl 10 c1.md 2>&1)" +check "a holder carrying bytes that look like field delimiters claims" "$?" "0" +out="$(GIT_LOCKS_NOW=1000 git-locks show --job ctl 2>&1)" +jfields "and cannot shadow a field: expires and job are the record's own" "${out}" 'expires=1010' 'job="ctl"' 'holder="a\u001eexpires: 5\u001fjob\u001e"' +valid "a show line with control bytes in the holder" "${out}" +out="$(git-locks claim --job ctl2 --holder $'two\nlines' c2.md 2>&1)" +check "a holder with a newline is refused at claim" "$?" "2" +out="$(git-locks sem acquire o --job ctl --holder $'a\nb' 2>&1)" +check "a holder with a newline is refused at sem acquire" "$?" "2" +out="$(git-locks sem acquire o --job ctl --holder $'a\rb' 2>&1)" +check "and so is a carriage return" "$?" "2" +out="$(git-locks with --job ctl2 --holder $'a\nb' c2.md -- true 2>&1)" +check "and a newline at with" "$?" "2" +git-locks check c2.md >/dev/null 2>&1 +check "none of those refusals left a lock behind" "$?" "0" + +out="$(LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 git-locks claim --job u --holder 'héloïse' 'café/naïve.md' 2>&1)" +check "a non-ASCII holder and path claim under a UTF-8 locale" "$?" "0" +out="$(LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 git-locks list 2>&1)" +check "and list under that locale exits 0" "$?" "0" +contains "with the holder intact" "${out}" '"holder":"héloïse"' +contains "and the path intact" "${out}" '"paths":["café/naïve.md"]' +valid "list lines with non-ASCII text" "${out}" +out="$(LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 git-locks check 'café/naïve.md' 2>&1)" +check "check sees it held" "$?" "1" + +out="$(git-locks with --job z --holder h --sem o --ttl 0 -- true 2>&1)" +check "with --sem refuses --ttl 0 before acquiring anything" "$?" "2" +out="$(git-locks sem show o 2>&1)" +rc=0 +[[ "${out}" != *'"job":"z"'* ]] || rc=1 +check "and left no slot for it" "${rc}" "0" +out="$(git-locks with --job z --holder h --sem 'bad name' -- true 2>&1)" +check "with --sem validates the semaphore name" "$?" "2" + +# A signal while with waits for the path lock must release the slot it already took. +git-locks sem create w1 --capacity 1 >/dev/null 2>&1 +git-locks claim --job blocker --holder o held.md >/dev/null 2>&1 +git-locks with --job waiter --holder h --sem w1 --wait 30 held.md -- true >/dev/null 2>&1 & +wpid=$! +sleep 2 +kill -TERM "${wpid}" 2>/dev/null +wait "${wpid}" 2>/dev/null +out="$(git-locks sem show w1 2>&1)" +jfields "a TERM during the lock wait released the semaphore slot with had taken" "${out}" 'live=0' + +# sweep: a lock renewed between sweep's read and its transaction is not deleted. +R="$(mkrepo)" +cd "${R}" || exit 2 +GIT_LOCKS_NOW=1000 git-locks claim --job renew --holder h --ttl 10 r.md >/dev/null 2>&1 +GATE6="$(mktemp -d "${TMPDIR:-/tmp}/git-locks-gate6.XXXXXX")/go" +GIT_LOCKS_NOW=2000 GIT_LOCKS_PAUSE_AFTER_READ="${GATE6}" git-locks sweep >/tmp/gl-sweep.out 2>&1 & +spid=$! +sleep 1 +GIT_LOCKS_NOW=2000 git-locks extend --job renew --ttl 100 >/dev/null 2>&1 +: >"${GATE6}" +wait "${spid}" +check "sweep exits 0 when the expired lock it saw was renewed underneath" "$?" "0" +out="$(cat /tmp/gl-sweep.out)" +check "and sweeps nothing" "${out}" "" +out="$(GIT_LOCKS_NOW=2000 git-locks ttl --job renew 2>&1)" +jfields "the renewed lock is still there with its new expiry" "${out}" 'expires=2100' + +out="$(git-locks version extra 2>&1)" +check "version takes no arguments" "$?" "2" + printf '\n%d passed, %d failed\n' "${PASS}" "${FAIL}" if ((FAIL > 0)); then printf 'failed: %s\n' "${FAILED[@]}"