diff --git a/.github/scripts/priority-completion.test.cjs b/.github/scripts/priority-completion.test.cjs new file mode 100644 index 000000000..557d71e73 --- /dev/null +++ b/.github/scripts/priority-completion.test.cjs @@ -0,0 +1,45 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); + +const workflow = fs.readFileSync(path.join(__dirname, '../workflows/go-ci.yml'), 'utf8'); + +function jobSection(name) { + const start = workflow.indexOf(`\n ${name}:\n`); + assert.notEqual(start, -1, `missing job ${name}`); + const rest = workflow.slice(start + 1); + const next = rest.slice(1).search(/^ [a-zA-Z0-9_-]+:\s*$/m); + return next < 0 ? rest : rest.slice(0, next + 1); +} + +test('all other entry jobs require successful priority completion', () => { + for (const name of [ + 'fmt', 'build', 'benchmark', 'reported_library_list', 'discovered_library_corpus', + 'stdlib-corpus', 'cross-runtime', 'test', 'arm-scan', 'race', 'coverage', + ]) { + const section = jobSection(name); + assert.match(section, /^ needs: discovered_library_priority$/m, name); + // Native needs success gating must not be bypassed by always()/!cancelled(). + assert.doesNotMatch(section, /^ if:/m, name); + } + assert.doesNotMatch(workflow, /^ priority_started:/m); +}); + +test('all failed shards run and no priority failure can become success', () => { + const priority = jobSection('discovered_library_priority'); + assert.match(priority, /^ fail-fast: false$/m); + assert.doesNotMatch(priority, /continue-on-error:/); + assert.match(priority, /^ needs: ci_policy$/m); + assert.doesNotMatch(priority, /^ if:/m); +}); + +test('cheap CI policy checks pass before expensive corpus or test jobs start', () => { + const policy = jobSection('ci_policy'); + assert.match(policy, /bash scripts\/install-ci-llvm22-ubuntu\.sh/); + assert.match(policy, /node --test \.github\/scripts\/priority-\*\.test\.cjs/); + assert.match(policy, /go test \. -run '\^TestCI' -count=1/); + assert.doesNotMatch(policy, /^ (needs|if):/m); + assert.ok(workflow.indexOf('\n ci_policy:\n') < + workflow.indexOf('\n discovered_library_priority:\n')); +}); diff --git a/.github/scripts/priority-jobs.test.cjs b/.github/scripts/priority-jobs.test.cjs new file mode 100644 index 000000000..32b6b8e0e --- /dev/null +++ b/.github/scripts/priority-jobs.test.cjs @@ -0,0 +1,88 @@ +const assert = require('node:assert/strict'); +const { createHash } = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); + +const workflow = fs.readFileSync(path.join(__dirname, '../workflows/go-ci.yml'), 'utf8'); + +function jobSection(name) { + const start = workflow.indexOf(`\n ${name}:\n`); + assert.notEqual(start, -1, `missing job ${name}`); + const rest = workflow.slice(start + 1); + const next = rest.slice(1).search(/^ [a-zA-Z0-9_-]+:\s*$/m); + return next < 0 ? rest : rest.slice(0, next + 1); +} + +function shards(section) { + const match = section.match(/^ shard:\n((?: - \d+\n)+)/m); + assert.ok(match, 'missing shard matrix'); + return Array.from(match[1].matchAll(/^ - (\d+)$/gm), + ([, value]) => Number(value)); +} + +test('priority and remaining matrices partition all 64 shards exactly once', () => { + const priority = shards(jobSection('discovered_library_priority')); + const remaining = shards(jobSection('discovered_library_corpus')); + // Retain instruction/ABI regressions and the cache cleanup failure from + // fork run 36509240233. + assert.deepEqual(priority, [5, 29, 36, 38, 42]); + assert.deepEqual([...priority, ...remaining].sort((a, b) => a - b), + Array.from({ length: 64 }, (_, index) => index)); + for (const name of ['discovered_library_priority', 'discovered_library_corpus']) { + const section = jobSection(name); + assert.match(section, /discovered-corpus \(shard \$\{\{ matrix\.shard \}\}\/64\)/); + assert.match(section, /check-discovered-library-corpus\.sh "\$\{\{ matrix\.shard \}\}" 64/); + } +}); + +test('failed exact versions rehash into priority shards', () => { + const priority = shards(jobSection('discovered_library_priority')); + for (const exactVersion of [ + 'github.com/Qitmeer/go-ethereum@v1.10.12', + 'github.com/RookieCoderrr/neo3fura-ctrverification@v0.0.0-20221201045318-9878de6dbeed', + 'github.com/SysVerification/gokv@v0.0.0-20250508184610-d007325b6ee8', + 'github.com/reddit/milvus/pkg/v3@v3.0.0-20260702082229-182134e29ebf', + 'github.com/apernet/hysteria/app/v2@v2.12.3', + 'github.com/HyNetwork/hysteria/app@v1.3.5', + ]) { + const hash = createHash('sha256').update(exactVersion).digest(); + const shard = Number(hash.readBigUInt64BE(0) % 64n); + assert.ok(priority.includes(shard), `${exactVersion} belongs to shard ${shard}`); + } +}); + +test('priority and remaining shards use identical test and artifact steps', () => { + function steps(name) { + const section = jobSection(name); + return section.slice(section.indexOf(' steps:\n')).trim(); + } + assert.equal(steps('discovered_library_priority'), steps('discovered_library_corpus')); + assert.match(jobSection('discovered_library_corpus_verify'), + /needs: \[discovered_library_priority, discovered_library_corpus\]/); +}); + +test('discovery matrices have no explicit concurrency cap', () => { + for (const name of ['discovered_library_priority', 'discovered_library_corpus']) { + assert.doesNotMatch(jobSection(name), /^ max-parallel:/m, name); + } +}); + +test('local discovery helpers use the CI shard count by default', () => { + for (const name of [ + 'check-discovered-library-corpus.sh', + 'discovery-status.sh', + 'update-assembly-ledger.sh', + ]) { + const script = fs.readFileSync(path.join(__dirname, '../../scripts', name), 'utf8'); + assert.match(script, /shard_count=\$\{2:-64\}/, name); + } +}); + +test('discovery matrices verify checksums against direct signed databases', () => { + for (const name of ['discovered_library_priority', 'discovered_library_corpus']) { + const section = jobSection(name); + assert.match(section, /^ GOSUMDB:.*sum\.golang\.google\.cn.*sum\.golang\.org https:\/\/sum\.golang\.org/m, name); + assert.doesNotMatch(section, /^ GOSUMDB: ['"]?off/m, name); + } +}); diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml index 1e1a769d7..8c1d52551 100644 --- a/.github/workflows/go-ci.yml +++ b/.github/workflows/go-ci.yml @@ -18,8 +18,82 @@ permissions: contents: read jobs: + ci_policy: + name: CI policy preflight + runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-latest' }} + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Install LLVM 22 + timeout-minutes: 14 + run: bash scripts/install-ci-llvm22-ubuntu.sh + + - name: Setup Go + uses: ./.github/actions/setup-go + with: + go-version: '1.27.1' + cache: true + + - name: Workflow scheduling regression tests + run: node --test .github/scripts/priority-*.test.cjs + + - name: CI policy tests + run: go test . -run '^TestCI' -count=1 + + discovered_library_priority: + name: discovered-corpus (shard ${{ matrix.shard }}/64) + needs: ci_policy + runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-latest' }} + strategy: + fail-fast: false + # Require every previously failing shard to pass before other work. + matrix: + shard: + - 5 + - 29 + - 36 + - 38 + - 42 + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Install LLVM 22 + timeout-minutes: 14 + run: bash scripts/install-ci-llvm22-ubuntu.sh + + - name: Setup Go + uses: ./.github/actions/setup-go + with: + # Discovery reports and the audited assembly ledger pin this exact + # compiler/assembler version; do not silently advance to 1.27.2. + go-version: '1.27.1' + cache: true + + - name: Translate and compile discovered assembly + env: + # Retained exact versions can outlive their public origin and the + # first proxy's cache. Verify against the signed database directly; + # some module proxies return 404 for checksum tiles they advertise. + GOPROXY: https://proxy.golang.org,https://goproxy.cn,direct + GOSUMDB: ${{ github.repository_owner == 'xgo-dev' && 'sum.golang.google.cn' || 'sum.golang.org https://sum.golang.org' }} + run: scripts/check-discovered-library-corpus.sh "${{ matrix.shard }}" 64 + + - name: Upload shard report + if: always() + uses: actions/upload-artifact@v6 + with: + name: discovered-corpus-shard-${{ matrix.shard }} + path: _out/discovered-library-corpus/shard-${{ matrix.shard }}.json + if-no-files-found: error + fmt: - runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-24.04' }} + needs: discovered_library_priority + runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-latest' }} steps: - name: Checkout @@ -41,7 +115,8 @@ jobs: fi build: - runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-24.04' }} + needs: discovered_library_priority + runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-latest' }} steps: - name: Checkout @@ -83,8 +158,9 @@ jobs: >/dev/null benchmark: + needs: discovered_library_priority name: compile benchmark (Go 1.27, LLVM 22) - runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-24.04' }} + runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-latest' }} timeout-minutes: 35 steps: @@ -116,8 +192,9 @@ jobs: if-no-files-found: error reported_library_list: + needs: discovered_library_priority name: Third-party corpus matrix - runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-24.04' }} + runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-latest' }} outputs: libraries: ${{ steps.matrix.outputs.libraries }} @@ -139,7 +216,7 @@ jobs: reported_library_corpus: name: third-party-corpus (${{ matrix.library }}) needs: reported_library_list - runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-24.04' }} + runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-latest' }} strategy: fail-fast: false matrix: @@ -168,15 +245,72 @@ jobs: run: scripts/check-reported-library-corpus.sh "$REPORTED_LIBRARY" discovered_library_corpus: - name: discovered-corpus (shard ${{ matrix.shard }}/32) - runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-24.04' }} + needs: discovered_library_priority + name: discovered-corpus (shard ${{ matrix.shard }}/64) + runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-latest' }} strategy: fail-fast: false - # Keep large generated-assembly candidates from exhausting runners in - # parallel. Every shard still runs and uploads its own report. - max-parallel: 8 matrix: - shard: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31] + shard: + - 0 + - 1 + - 2 + - 3 + - 4 + - 6 + - 7 + - 8 + - 9 + - 10 + - 11 + - 12 + - 13 + - 14 + - 15 + - 16 + - 17 + - 18 + - 19 + - 20 + - 21 + - 22 + - 23 + - 24 + - 25 + - 26 + - 27 + - 28 + - 30 + - 31 + - 32 + - 33 + - 34 + - 35 + - 37 + - 39 + - 40 + - 41 + - 43 + - 44 + - 45 + - 46 + - 47 + - 48 + - 49 + - 50 + - 51 + - 52 + - 53 + - 54 + - 55 + - 56 + - 57 + - 58 + - 59 + - 60 + - 61 + - 62 + - 63 steps: - name: Checkout @@ -197,9 +331,11 @@ jobs: - name: Translate and compile discovered assembly env: # Retained exact versions can outlive their public origin and the - # first proxy's cache. Keep normal Go checksum-database verification. + # first proxy's cache. Verify against the signed database directly; + # some module proxies return 404 for checksum tiles they advertise. GOPROXY: https://proxy.golang.org,https://goproxy.cn,direct - run: scripts/check-discovered-library-corpus.sh "${{ matrix.shard }}" 32 + GOSUMDB: ${{ github.repository_owner == 'xgo-dev' && 'sum.golang.google.cn' || 'sum.golang.org https://sum.golang.org' }} + run: scripts/check-discovered-library-corpus.sh "${{ matrix.shard }}" 64 - name: Upload shard report if: always() @@ -211,9 +347,10 @@ jobs: discovered_library_corpus_verify: name: discovered-corpus aggregate verification - needs: discovered_library_corpus + needs: [discovered_library_priority, discovered_library_corpus] if: ${{ !cancelled() }} - runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-24.04' }} + # Keep the final artifact-only gate on the stable public-hosted image. + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v7 @@ -250,6 +387,7 @@ jobs: run: scripts/verify-discovered-library-corpus.sh stdlib-corpus: + needs: discovered_library_priority name: stdlib-corpus (${{ matrix.os }}, Go ${{ matrix.go-version }}) runs-on: ${{ github.repository_owner == 'xgo-dev' && matrix.runner || matrix.os }} strategy: @@ -332,8 +470,9 @@ jobs: scripts/check-go-asm-coverage.sh cross-runtime: + needs: discovered_library_priority name: cross compile, link, and run (Linux) - runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-24.04' }} + runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-latest' }} steps: - name: Checkout @@ -362,6 +501,7 @@ jobs: run: go test . -run '^(TestCrossLinuxRuntimeMatrix|TestARM64Conformance|TestARM64RawSQSHLURuntimeSemantics|TestARMIntegerMemoryRuntimeCross|TestARMRawSaturationRuntimeCross)' -count=1 -timeout=20m -v test: + needs: discovered_library_priority name: test (${{ matrix.os }}, ${{ matrix.go-version }}, ${{ matrix.nested_modules }}) timeout-minutes: 60 permissions: @@ -478,7 +618,8 @@ jobs: verbose: true arm-scan: - runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-24.04' }} + needs: discovered_library_priority + runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-latest' }} strategy: fail-fast: false matrix: @@ -529,7 +670,8 @@ jobs: PY race: - runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-24.04' }} + needs: discovered_library_priority + runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-latest' }} timeout-minutes: 75 steps: @@ -550,7 +692,8 @@ jobs: run: go test -race -timeout=60m ./... coverage: - runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-24.04' }} + needs: discovered_library_priority + runs-on: ${{ github.repository_owner == 'xgo-dev' && fromJSON('["qiniu", "ubuntu-24.04-large"]') || 'ubuntu-latest' }} timeout-minutes: 60 permissions: contents: read diff --git a/AGENTS.md b/AGENTS.md index 45e3a48d0..5be800b95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,12 @@ tests establish the relevant runtime semantics. exact file and target pinned in `testdata/corpus/private-extensions.json`. Verify the source hash and current Go assembler, compile every other applicable file, and count the candidate separately from passes. +- A native-byte-layout/JIT gap requires an explicitly reviewed exception + policy before promotion. A proposal may be tested in the fork Draft PR, + clearly labeled provisional. Pin its module, version, file and targets in + `testdata/corpus/native-layout.json`. Recheck source SHA-256 and Go object + bytes for each target; compile all other assembly and count the skip outside + passes. Do not use this for an ordinary unsupported instruction. - Freeze source, tools and ledger during corpus verification. A changed input invalidates the run. Do not rebase, rewrite or import records into that tree while tests are running; use a separate persistent worktree for development. @@ -62,6 +68,9 @@ tests establish the relevant runtime semantics. compressed discovery results or obsolete per-run ledgers, even in history. - Commit verified development promptly. Keep PR 40 draft until current-head tests, CI, review and coverage meet the completion gates. +- Stage PR 40 repairs in a separate branch and Draft PR inside `cpunion`. + Do not update its upstream-connected head until fork CI passes. Follow + [the fork validation procedure](docs/development/validation.md#fork-first-ci). ## Common commands diff --git a/amd64_raw.go b/amd64_raw.go index 77b80d9cf..8ab53075b 100644 --- a/amd64_raw.go +++ b/amd64_raw.go @@ -10113,6 +10113,34 @@ func decodedX86GoSyntax(inst x86asm.Inst, encoding []byte) (string, error) { } switch inst.Op { + case x86asm.RDFSBASE, x86asm.RDGSBASE, x86asm.WRFSBASE, x86asm.WRGSBASE: + // x/arch prints widthless Intel names. Reuse the complete named + // FSGSBASE grammar, selecting L/Q from the actual GPR: an ignored + // 66 prefix can set DataSize=16 while the operand remains 32-bit. + register, ok := inst.Args[0].(x86asm.Reg) + bits := decodedX86RegisterBits(register) + if !ok || inst.Mode != 64 || (bits != 32 && bits != 64) { + return "", fmt.Errorf("raw %s requires a 32/64-bit GPR in 64-bit mode", inst.Op) + } + for _, extra := range inst.Args[1:] { + if extra != nil { + return "", fmt.Errorf("raw %s requires exactly one register", inst.Op) + } + } + for _, prefix := range inst.Prefix { + if prefix&0xff == x86asm.PrefixLOCK || prefix&x86asm.PrefixInvalid != 0 { + return "", fmt.Errorf("raw %s has an invalid prefix %s", inst.Op, prefix) + } + } + // Segment/address/operand overrides do not affect this register-only + // family. Clear them only after rejecting invalid (not ignored) ones. + inst.Prefix = x86asm.Prefixes{} + syntax = x86asm.GoSyntax(inst, 0, nil) + width := "L" + if bits == 64 { + width = "Q" + } + replaceOp(inst.Op.String() + width) case x86asm.INC, x86asm.DEC: bits := 0 switch destination := inst.Args[0].(type) { diff --git a/amd64_raw_segment_base_test.go b/amd64_raw_segment_base_test.go new file mode 100644 index 000000000..5a36ecaaa --- /dev/null +++ b/amd64_raw_segment_base_test.go @@ -0,0 +1,187 @@ +package plan9asm + +import ( + "fmt" + "strings" + "testing" + + "golang.org/x/arch/x86/x86asm" +) + +// Intel's F3 0F AE /0..3 register encodings correspond to all eight rows in +// Go's asm6.go: RDFSBASE, RDGSBASE, WRFSBASE and WRGSBASE, each with L/Q width. +var rawSegmentBaseTestOps = [...]string{"RDFSBASE", "RDGSBASE", "WRFSBASE", "WRGSBASE"} + +func rawSegmentBaseTestEncoding(operation, bits, register int) []byte { + code := []byte{0xf3} + rex := byte(0x40) + if bits == 64 { + rex |= 8 + } + if register >= 8 { + rex |= 1 + } + if rex != 0x40 { + code = append(code, rex) + } + return append(code, 0x0f, 0xae, byte(0xc0|operation<<3|register&7)) +} + +func TestX86RawSegmentBaseCompleteRegisterFamily(t *testing.T) { + if len(amd64SegmentBaseSpecs) != 2*len(rawSegmentBaseTestOps) { + t.Fatal("raw encoding tests must cover the complete named segment-base grammar") + } + registers := [...]Reg{AX, CX, DX, BX, SP, BP, SI, DI, "R8", "R9", "R10", "R11", "R12", "R13", "R14", "R15"} + for operation, name := range rawSegmentBaseTestOps { + for _, width := range []struct { + bits int + suffix string + }{{32, "L"}, {64, "Q"}} { + for register, wantRegister := range registers { + wantOp := Op(name + width.suffix) + t.Run(string(wantOp)+"/"+string(wantRegister), func(t *testing.T) { + code := rawSegmentBaseTestEncoding(operation, width.bits, register) + decoded, err := decodeX86RawDirectiveGroup(code, 64, 0, "segment-base bytes", nil) + if err != nil { + t.Fatal(err) + } + if len(decoded) != 1 || decoded[0].Op != wantOp || len(decoded[0].Args) != 1 || + decoded[0].Args[0].Kind != OpReg || decoded[0].Args[0].Reg != wantRegister { + t.Fatalf("%x decoded as %+v, want %s %s", code, decoded, wantOp, wantRegister) + } + if _, ok := amd64SegmentBaseSpecs[wantOp]; !ok { + t.Fatalf("raw opcode %s has no typed named grammar", wantOp) + } + }) + } + } + } +} + +func TestX86RawSegmentBaseIgnoredPrefixesPreserveRegisterWidth(t *testing.T) { + for operation, name := range rawSegmentBaseTestOps { + for _, bits := range []int{32, 64} { + for _, prefix := range []byte{0x66, 0x67, 0x64, 0x65} { + for _, afterREP := range []bool{false, true} { + code := rawSegmentBaseTestEncoding(operation, bits, 11) + if afterREP { + code = append([]byte{0xf3, prefix}, code[1:]...) + } else { + code = append([]byte{prefix}, code...) + } + inst, err := x86asm.Decode(code, 64) + if err != nil || inst.Len != len(code) { + t.Fatalf("decode %x: %+v, %v", code, inst, err) + } + got, err := decodedX86GoSyntax(inst, code) + width := "L" + if bits == 64 { + width = "Q" + } + want := name + width + " R11" + if err != nil || got != want { + t.Errorf("%x (DataSize=%d): got %q, %v; want %q", code, inst.DataSize, got, err, want) + } + } + } + } + } +} + +func TestX86RawSegmentBaseRejectsInvalidDecodedForms(t *testing.T) { + for _, op := range []x86asm.Op{x86asm.RDFSBASE, x86asm.RDGSBASE, x86asm.WRFSBASE, x86asm.WRGSBASE} { + for _, args := range []x86asm.Args{ + {}, {x86asm.Imm(1)}, {x86asm.Mem{Base: x86asm.RAX}}, + {x86asm.AL}, {x86asm.AX}, {x86asm.X0}, + {x86asm.RAX, x86asm.RBX}, {x86asm.RAX, nil, nil, x86asm.RBX}, + } { + inst := x86asm.Inst{Op: op, Mode: 64, DataSize: 64, Args: args} + if got, err := decodedX86GoSyntax(inst, nil); err == nil { + t.Errorf("accepted invalid %s operands %v as %q", op, args, got) + } + } + } + for operation := range rawSegmentBaseTestOps { + code := rawSegmentBaseTestEncoding(operation, 64, 3) + for _, invalid := range []struct { + name string + code []byte + mode int + }{ + {"lock", append([]byte{0xf0}, code...), 64}, + {"memory", []byte{0xf3, 0x48, 0x0f, 0xae, byte(operation << 3)}, 64}, + {"truncated", code[:len(code)-1], 64}, + {"32-bit-mode", rawSegmentBaseTestEncoding(operation, 32, 3), 32}, + } { + t.Run(rawSegmentBaseTestOps[operation]+"/"+invalid.name, func(t *testing.T) { + if got, err := decodeX86RawDirectiveGroup(invalid.code, invalid.mode, 0, "invalid segment-base bytes", nil); err == nil { + t.Fatalf("accepted invalid %x as %+v", invalid.code, got) + } + }) + } + } +} + +func TestTranslateX86RawSegmentBaseMatchesNamedFamily(t *testing.T) { + llc := findLLVM22Tool("llc") + if llc == "" { + t.Fatal("LLVM 22 llc not found") + } + for _, triple := range []string{ + "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc", + } { + t.Run(triple, func(t *testing.T) { + var raw, named strings.Builder + for _, source := range []*strings.Builder{&raw, &named} { + source.WriteString("TEXT segmentbase(SB),$0-0\nMOVQ $-1, BX\n") + } + // Keep the whole reported gVisor sequence, including SWAPGS before + // RDGSBASEQ, as well as every sibling operation and operand width. + for _, value := range []byte{0x0f, 0x01, 0xf8, 0xf3, 0x48, 0x0f, 0xae, 0xcb} { + fmt.Fprintf(&raw, "BYTE $%#x\n", value) + } + named.WriteString("SWAPGS\nRDGSBASEQ BX\n") + for operation, name := range rawSegmentBaseTestOps { + for _, bits := range []int{32, 64} { + for _, value := range rawSegmentBaseTestEncoding(operation, bits, 3) { + fmt.Fprintf(&raw, "BYTE $%#x\n", value) + } + width := "L" + if bits == 64 { + width = "Q" + } + fmt.Fprintf(&named, "%s%s BX\n", name, width) + } + } + var irs []string + for _, source := range []*strings.Builder{&raw, &named} { + source.WriteString("RET\n") + requireX86GoAssemblerResult(t, "amd64", source.String(), true) + file, err := Parse(ArchAMD64, source.String()) + if err != nil { + t.Fatal(err) + } + ir, err := Translate(file, Options{ + Goarch: "amd64", TargetTriple: triple, + Sigs: map[string]FuncSig{"segmentbase": {Name: "segmentbase", Ret: Void}}, + }) + if err != nil { + t.Fatal(err) + } + // LLVM assigns a different temporary filename on each parse. + // Compare every semantic line, excluding only that metadata. + lines := strings.Split(ir, "\n") + for index, line := range lines { + if strings.HasPrefix(line, "; ModuleID = ") || strings.HasPrefix(line, "source_filename = ") { + lines[index] = "" + } + } + irs = append(irs, strings.Join(lines, "\n")) + } + if irs[0] != irs[1] { + t.Fatalf("raw and named segment-base semantics differ:\nraw:\n%s\nnamed:\n%s", irs[0], irs[1]) + } + compileLLVMToObject(t, llc, triple, "raw-segment-base.ll", "raw-segment-base.o", irs[0]) + }) + } +} diff --git a/arm64_lower_sve_dup_immediate.go b/arm64_lower_sve_dup_immediate.go index db0733f7b..05f5cc842 100644 --- a/arm64_lower_sve_dup_immediate.go +++ b/arm64_lower_sve_dup_immediate.go @@ -20,7 +20,7 @@ type arm64RawSVEDupElement struct { } func decodeARM64RawSVEDupElement(word uint32) (arm64RawSVEDupElement, bool) { - if word&0xffa0fc00 != 0x05202000 { + if word&0xff20fc00 != 0x05202000 { return arm64RawSVEDupElement{}, false } imm5 := int(word>>16) & 31 @@ -39,17 +39,9 @@ func decodeARM64RawSVEDupElement(word uint32) (arm64RawSVEDupElement, bool) { source: int(word>>5) & 31, destination: int(word) & 31, } - if size == 4 { - if imm5 != 16 { - return arm64RawSVEDupElement{}, false - } - form.lane = int(word>>22) & 1 - } else { - if word&(1<<22) != 0 { - return arm64RawSVEDupElement{}, false - } - form.lane = imm5 >> (size + 1) - } + // imm2:tsz describes an element in the first 512 bits, not just the + // minimum 128-bit vector. The two upper index bits are shared by all widths. + form.lane = (int(word>>22&3)<<5 | imm5) >> (size + 1) return form, true } @@ -120,7 +112,7 @@ func arm64ParseSVEDupElementReg(operand Operand) (index, elementBits, lane int, } elementBits, ok = map[string]int{"B": 8, "H": 16, "S": 32, "D": 64, "Q": 128}[name[dot+1:open]] lane, err = strconv.Atoi(name[open+1 : len(name)-1]) - if err != nil || !ok || lane < 0 || lane >= 256/elementBits { + if err != nil || !ok || lane < 0 || lane >= 512/elementBits { return 0, 0, 0, false } return index, elementBits, lane, true @@ -212,7 +204,25 @@ func (c *arm64Ctx) lowerRawSVEDupElement(form arm64RawSVEDupElement) error { element := c.newTmp() inserted := c.newTmp() splat := c.newTmp() - fmt.Fprintf(c.b, " %%%s = extractelement %s %s, i64 %d\n", element, vectorType, typedSource, form.lane) + index := fmt.Sprint(form.lane) + valid := "" + if form.lane >= lanes { + // The architecture returns zero beyond the current VL. An unchecked + // extractelement would instead create LLVM poison. Clamp the extraction + // itself, then select zero, so both evaluated operands remain defined. + vscale, length, inRange, safeIndex := c.newTmp(), c.newTmp(), c.newTmp(), c.newTmp() + fmt.Fprintf(c.b, " %%%s = call i64 @llvm.vscale.i64()\n", vscale) + fmt.Fprintf(c.b, " %%%s = mul i64 %%%s, %d\n", length, vscale, lanes) + fmt.Fprintf(c.b, " %%%s = icmp ugt i64 %%%s, %d\n", inRange, length, form.lane) + fmt.Fprintf(c.b, " %%%s = select i1 %%%s, i64 %d, i64 0\n", safeIndex, inRange, form.lane) + index, valid = "%"+safeIndex, "%"+inRange + } + fmt.Fprintf(c.b, " %%%s = extractelement %s %s, i64 %s\n", element, vectorType, typedSource, index) + if valid != "" { + selected := c.newTmp() + fmt.Fprintf(c.b, " %%%s = select i1 %s, i%d %%%s, i%d 0\n", selected, valid, form.elementBits, element, form.elementBits) + element = selected + } fmt.Fprintf(c.b, " %%%s = insertelement %s poison, i%d %%%s, i64 0\n", inserted, vectorType, form.elementBits, element) fmt.Fprintf(c.b, " %%%s = shufflevector %s %%%s, %s poison, zeroinitializer\n", splat, vectorType, inserted, vectorType, lanes) result := "%" + splat diff --git a/arm64_lower_word_sve_dup_immediate_test.go b/arm64_lower_word_sve_dup_immediate_test.go index 71ee2a6f8..bbef9525b 100644 --- a/arm64_lower_word_sve_dup_immediate_test.go +++ b/arm64_lower_word_sve_dup_immediate_test.go @@ -100,14 +100,14 @@ func encodeARM64RawSVEDupElement(elementBits, lane, source, destination int) uin } size := map[int]int{8: 0, 16: 1, 32: 2, 64: 3}[elementBits] imm5 := lane<<(size+1) | 1<>5)<<22 | uint32(source)<<5 | uint32(destination) } func TestARM64RawSVEDupElementDecoderCoversEveryEncodingField(t *testing.T) { count := 0 for _, domain := range []struct { bits, lanes int - }{{8, 16}, {16, 8}, {32, 4}, {64, 2}, {128, 2}} { + }{{8, 64}, {16, 32}, {32, 16}, {64, 8}, {128, 4}} { for lane := 0; lane < domain.lanes; lane++ { for source := 0; source < 32; source++ { for destination := 0; destination < 32; destination++ { @@ -121,13 +121,13 @@ func TestARM64RawSVEDupElementDecoderCoversEveryEncodingField(t *testing.T) { } } } - if count != (16+8+4+2+2)*32*32 { + if count != (64+32+16+8+4)*32*32 { t.Fatalf("covered %d SVE DUP element encodings", count) } } func TestARM64RawSVEDupElementRejectsReservedAndAdjacentEncodings(t *testing.T) { - for _, word := range []uint32{0x05202000, 0x05612000, 0x05202400} { + for _, word := range []uint32{0x05202000, 0x05602000, 0x05a02000, 0x05e02000, 0x05202400} { if _, ok := decodeARM64RawSVEDupElement(word); ok { t.Fatalf("SVE DUP element decoder accepted reserved/adjacent encoding %#08x", word) } diff --git a/arm64_raw_pool_affine.go b/arm64_raw_pool_affine.go index 08a2577fc..beeefa285 100644 --- a/arm64_raw_pool_affine.go +++ b/arm64_raw_pool_affine.go @@ -224,13 +224,22 @@ func (flow *arm64RawPoolValues) affineIntervalProof(at int, expression arm64Pool continue } visited[state] = true - if constraint, ok := flow.loopBounds[state.at]; ok { + invariantLatch, invariantLoop := flow.rewindInvariantLoop(&state) + if constraint, ok := flow.loopBounds[state.at]; ok && !invariantLoop { value, bounded := flow.affineConstraintBound(state.at, state.expression, constraint) if !bounded || value == arm64PoolUnknownInterval { value, bounded = flow.carriedLoopBound(state.at, state.expression, constraint) } if bounded && value != arm64PoolUnknownInterval { - addResult(value) + if state.bound.low > value.low { + value.low = state.bound.low + } + if state.bound.high < value.high { + value.high = state.bound.high + } + if value.low <= value.high { + addResult(value) + } continue } } @@ -305,6 +314,9 @@ func (flow *arm64RawPoolValues) affineIntervalProof(at int, expression arm64Pool } predecessors: for _, previous := range flow.before[state.at] { + if invariantLoop && previous == invariantLatch { + continue + } if previous < 0 { addResult(bound) continue @@ -366,6 +378,11 @@ func (flow *arm64RawPoolValues) affineIntervalProof(at int, expression arm64Pool // value. An unrelated masked predicate may be forgotten below; // recursively proving it must not consume the address's budget. interval, bounded := flow.affineMaskInterval(previous, word) + if bounded && interval.low != interval.high { + // A partial mask may still subtract only fixed bits. Keep + // that relation instead of replacing it by an independent range. + value, _, affine, _ = flow.affineLogicalDefinition(previous, word) + } if !bounded { interval, bounded = flow.affineMoveKeepInterval(previous, word) } @@ -401,6 +418,19 @@ func (flow *arm64RawPoolValues) affineIntervalProof(at int, expression arm64Pool continue predecessors } } + if !affine && next.bound.low != next.bound.high { + if span, ok := flow.maskedDifferenceBound(previous, word, next.expression, next.constraints[:next.count]); ok { + if span.low > next.bound.low { + next.bound.low = span.low + } + if span.high < next.bound.high { + next.bound.high = span.high + } + if next.bound.low > next.bound.high { + continue predecessors + } + } + } } for index := 0; index < 31; index++ { if writes&(1<> 5 & 31) value = arm64PoolRegisterExpression(base) scale := int64(1) - if ins.Op == arm64asm.SUB || ins.Op == arm64asm.SUBS { + if word&(1<<30) != 0 { scale = -1 } switch { diff --git a/arm64_raw_pool_affine_guard.go b/arm64_raw_pool_affine_guard.go index 452980de9..5bd8935fc 100644 --- a/arm64_raw_pool_affine_guard.go +++ b/arm64_raw_pool_affine_guard.go @@ -103,36 +103,24 @@ func (flow *arm64RawPoolValues) affineEdgeConstraint(edge arm64RawPoolEdge) (arm } return constraint, true } - // CMP/CMN immediate, 64-bit only. SP and W comparisons do not establish - // this expression domain's 64-bit GP constraints. - if compare&0xbf80001f != 0xb100001f || compare>>5&31 == 31 { + // A register limit must be an independently proved numeric constant at + // the comparison, not a later value or a relocated pool offset. + register, immediate, subtract, valid := flow.compareConstant(compare, after-1) + if !valid { return constraint, false } - constraint.expression = arm64PoolRegisterExpression(int(compare >> 5 & 31)) + constraint.expression = arm64PoolRegisterExpression(register) if constraint.expression.registerMask()&clobbered != 0 { constraint.after = after } constraint.interval = arm64PoolUnknownInterval - immediate := uint64(compare >> 10 & 4095) - if compare&(1<<22) != 0 { - immediate <<= 12 - } - if compare&(1<<30) == 0 { // CMN: C is the carry out of unsigned addition. + if !subtract { // CMN: C is the carry out of unsigned addition. if immediate == 0 { return constraint, false } - switch condition { - case 2: - constraint.interval.low = math.MaxUint64 - immediate + 1 - case 3: - constraint.interval.high = math.MaxUint64 - immediate - case 0: - constraint.interval.low = -immediate - constraint.interval.high = -immediate - default: - return constraint, false - } - return constraint, true + // For a nonzero constant, carry and zero have the same unsigned + // conditions as CMP against its modular negation. + immediate = -immediate } switch condition { case 0: @@ -145,6 +133,9 @@ func (flow *arm64RawPoolValues) affineEdgeConstraint(edge arm64RawPoolEdge) (arm } constraint.interval.high = immediate - 1 case 8: + if immediate == math.MaxUint64 { + return constraint, false + } constraint.interval.low = immediate + 1 case 9: constraint.interval.high = immediate @@ -154,6 +145,58 @@ func (flow *arm64RawPoolValues) affineEdgeConstraint(edge arm64RawPoolEdge) (arm return constraint, true } +// affineFlagSourceBefore already validated the CMP/CMN encoding with the +// architecture decoder. Cover its immediate, shifted-register and extended- +// register operand classes. SP and W comparisons stay outside this X domain. +func (flow *arm64RawPoolValues) compareConstant(word uint32, at int) (register int, value uint64, subtract, ok bool) { + register, subtract = int(word>>5&31), word&(1<<30) != 0 + if register == 31 { + return + } + if word&0xbf80001f == 0xb100001f { + value = uint64(word >> 10 & 4095) + if word&(1<<22) != 0 { + value <<= 12 + } + ok = true + return + } + if word&0xbf00001f != 0xab00001f { + return + } + numeric := flow.numericValues() + bound := numeric.invariantInterval(at, arm64PoolRegisterExpression(int(word>>16&31))) + flow.affineWork = numeric.affineWork + if bound.low != bound.high { + return + } + value = bound.low + if word&(1<<21) == 0 { + shift := word >> 10 & 63 + switch word >> 22 & 3 { + case 0: + value <<= shift + case 1: + value >>= shift + case 2: + value = uint64(int64(value) >> shift) + default: + return + } + } else { + width := uint32(8) << (word >> 13 & 3) + value <<= 64 - width + if word&(1<<15) != 0 { + value = uint64(int64(value) >> (64 - width)) + } else { + value >>= 64 - width + } + value <<= word >> 10 & 7 + } + ok = true + return +} + // A tested bit can exclude a path only when another reaching guard proves // that bit's value for the entire interval. Testing a low W bit never bounds // the untested high bits of X. Keep both facts until definitions normalize diff --git a/arm64_raw_pool_affine_mask.go b/arm64_raw_pool_affine_mask.go index 5a7c1d868..79cf7afbc 100644 --- a/arm64_raw_pool_affine_mask.go +++ b/arm64_raw_pool_affine_mask.go @@ -2,6 +2,7 @@ package plan9asm import ( "encoding/binary" + "math" "math/bits" "golang.org/x/arch/arm64/arm64asm" @@ -47,13 +48,32 @@ func (flow *arm64RawPoolValues) affineMaskInterval(at int, word uint32) (arm64Po } func arm64PoolMaskInterval(input arm64PoolInterval, mask uint64) arm64PoolInterval { - // Bits above the highest differing endpoint bit are constant throughout - // an unsigned interval. Everything below is conservatively free to vary. - varying := uint64(1)< input.high { - high = input.high // AND never increases an unsigned operand. - } - return arm64PoolInterval{low, high} + if input.low > input.high { + return arm64PoolUnknownInterval + } + // Partition into aligned power-of-two blocks. Each block has independent + // low bits, so its masked extrema are exact. Union at most 128 blocks; + // treating every bit below the endpoints' common prefix as free would + // incorrectly include zero in [8,19] & 24 and lose loop termination proof. + result := arm64PoolInterval{math.MaxUint64, 0} + for low := input.low; ; { + remaining := input.high - low + width := bits.TrailingZeros64(low) + if remaining != math.MaxUint64 { + if available := bits.Len64(remaining+1) - 1; available < width { + width = available + } + } + varying := uint64(1)< result.high { + result.high = maximum + } + if varying == remaining { + return result + } + low += varying + 1 + } } diff --git a/arm64_raw_pool_affine_test.go b/arm64_raw_pool_affine_test.go index 988cc7160..ce2a0dcb6 100644 --- a/arm64_raw_pool_affine_test.go +++ b/arm64_raw_pool_affine_test.go @@ -62,6 +62,28 @@ func TestARM64PoolMaskIntervals(t *testing.T) { if got := arm64PoolMaskInterval(arm64PoolInterval{8, 15}, 24); got != (arm64PoolInterval{8, 8}) { t.Fatalf("guarded mask: %+v", got) } + if got := arm64PoolMaskInterval(arm64PoolInterval{8, 19}, 24); got != (arm64PoolInterval{8, 16}) { + t.Fatalf("mask crossing a power-of-two boundary: %+v", got) + } + for low := uint64(0); low < 32; low++ { + for high := low; high < 32; high++ { + for mask := uint64(0); mask < 32; mask++ { + want := arm64PoolInterval{math.MaxUint64, 0} + for value := low; value <= high; value++ { + masked := value & mask + if masked < want.low { + want.low = masked + } + if masked > want.high { + want.high = masked + } + } + if got := arm64PoolMaskInterval(arm64PoolInterval{low, high}, mask); got != want { + t.Fatalf("[%d,%d] & %d = %+v, want %+v", low, high, mask, got, want) + } + } + } + } } func arm64RawPoolAffineIR(t *testing.T, triple string) string { diff --git a/arm64_raw_pool_alias_test.go b/arm64_raw_pool_alias_test.go index ccf7e1863..418cebdbc 100644 --- a/arm64_raw_pool_alias_test.go +++ b/arm64_raw_pool_alias_test.go @@ -62,6 +62,9 @@ func TestARM64RawPoolAliases(t *testing.T) { {"shifted-add", "and x1, x0, #3\nadd x12, x9, x1, lsl #3\nldr x0, [x12]", true}, {"shifted-sub", "and x1, x0, #1\nsub x12, x9, x1, lsl #3\nldr x0, [x12]", true}, {"commuted-add", "and x1, x0, #15\nadd x12, x1, x9\nldr x0, [x12]", true}, + {"masked-numeric-difference", "and x1, x0, #15\nadd x1, x1, #8\nand x2, x1, #24\nsub x3, x1, x2\nadd x12, x9, x3\nldrb w0, [x12]", true}, + {"masked-address-not-relocated", "and x1, x9, #-8\nsub x12, x9, x1\nldrb w0, [x12]", false}, + {"masked-address-not-an-index", "and x1, x9, #-8\nsub x2, x9, x1\nadd x12, x9, x2\nldrb w0, [x12]", false}, {"uxtw", "and w1, w0, #3\nadd x12, x9, w1, uxtw #3\nldr x0, [x12]", true}, {"sxtw-nonnegative", "and w1, w0, #3\nadd x12, x9, w1, sxtw #3\nldr x0, [x12]", true}, {"unchanging-loop", "mov x12, x9\nldr x0, [x12]\ncbnz x1, #-4", true}, diff --git a/arm64_raw_pool_carried.go b/arm64_raw_pool_carried.go index 34723c07c..b8f2c624e 100644 --- a/arm64_raw_pool_carried.go +++ b/arm64_raw_pool_carried.go @@ -2,6 +2,39 @@ package plan9asm import "math" +// Rewind an unchanged query through a certified loop without replacing it by +// an interval. This retains relationships to earlier definitions, such as a +// nonzero remainder guarded using a different copy of the original length. +// Only active constraints independently preserved by the entire body survive; +// a counter predicate from one iteration must not constrain its entry value. +func (flow *arm64RawPoolValues) rewindInvariantLoop(state *arm64PoolAffineState) (int, bool) { + latch, ok := flow.loopLatches[state.at] + if !ok || flow.affineDirect { + return 0, false + } + delta, valid := flow.loopExpressionDelta(state.at, latch, state.expression) + if !valid || delta != 0 { + return 0, false + } + count := 0 + for _, constraint := range state.constraints[:state.count] { + if constraint.after != 0 { + continue + } + change, preserved := flow.loopExpressionDelta(state.at, latch, constraint.expression) + if !preserved || change != 0 { + continue + } + state.constraints[count] = constraint + count++ + } + for index := count; index < state.count; index++ { + state.constraints[index] = arm64PoolConstraint{} + } + state.count = count + return latch, true +} + // The loop body is already certified straight-line and single-entry. Substitute // its effects backwards to prove a constant per-iteration delta; an unknown // load result, nonlinear update or changing coefficient is not such a proof. @@ -27,8 +60,8 @@ func (flow *arm64RawPoolValues) loopExpressionDelta(head, latch int, expression } // A carried address and the remaining count can change together even though -// they occupy unrelated registers: p -= 8 and remaining -= 1 preserve -// p-8*remaining. Prove that invariant from the external entries, then combine +// they occupy unrelated registers: p -= 64 and remaining += 8 preserve +// p+8*remaining. Prove that invariant from the external entries, then combine // it with the independently established counter range. No loop is unrolled or // assumed to run once, and entry analysis cannot recursively use this rule. func (flow *arm64RawPoolValues) carriedLoopBound(head int, query arm64PoolAffine, counter arm64PoolConstraint) (arm64PoolInterval, bool) { @@ -37,14 +70,14 @@ func (flow *arm64RawPoolValues) carriedLoopBound(head int, query arm64PoolAffine return arm64PoolInterval{}, false } step, valid := flow.loopExpressionDelta(head, latch, counter.expression) - if !valid || step != -1 { + if !valid || step == 0 || step < -(1<<30) || step > 1<<30 { return arm64PoolInterval{}, false } delta, valid := flow.loopExpressionDelta(head, latch, query) - if !valid || delta < -(1<<30) || delta > 1<<30 { + if !valid || delta < -(1<<30) || delta > 1<<30 || delta%step != 0 { return arm64PoolInterval{}, false } - scale := -delta + scale := delta / step invariant := query if !invariant.add(counter.expression, -scale) { return arm64PoolInterval{}, false @@ -54,24 +87,40 @@ func (flow *arm64RawPoolValues) carriedLoopBound(head int, query arm64PoolAffine return arm64PoolInterval{}, false } entry.poolOrigins = flow.poolOrigins + entry.loopLatches = nil // Entry proof must not recursively summarize another loop. // Keep the entire relation while substituting entry definitions. Bounding // p and remaining separately would lose their correlation before the // cancellation that establishes the invariant constant. - value := entry.invariantIntervalProof(head, invariant, true) - if value == arm64PoolUnknownInterval { - work := entry.affineWork - value = entry.affineInterval(head, invariant) - entry.affineWork += work + nearest := counter.interval.low + if nearest > math.MaxInt64 { + nearest = counter.interval.high } - flow.affineWork += entry.affineWork - if value == arm64PoolUnknownInterval || flow.affineWork >= 16384 { - return arm64PoolInterval{}, false - } - result := arm64PoolIntervalImage(counter.interval, scale, value.low) - width := value.high - value.low - if result.high > math.MaxUint64-width { - return arm64PoolInterval{}, false + for _, anchor := range []uint64{0, nearest} { + // Shift the reference count, not the represented address. An invariant + // spanning unsigned zero may become one ordinary interval when centered + // at the counter endpoint nearest zero. No wraparound is clamped away. + anchored := invariant + anchored.constant += uint64(scale) * anchor + value := entry.invariantIntervalProof(head, anchored, true) + if value == arm64PoolUnknownInterval { + work := entry.affineWork + value = entry.affineInterval(head, anchored) + entry.affineWork += work + } + flow.affineWork += entry.affineWork + if flow.affineWork >= 16384 { + return arm64PoolInterval{}, false + } + if value == arm64PoolUnknownInterval { + continue + } + result := arm64PoolIntervalImage(counter.interval, scale, value.low-uint64(scale)*anchor) + width := value.high - value.low + if result == arm64PoolUnknownInterval || result.high > math.MaxUint64-width { + continue + } + result.high += width + return result, true } - result.high += width - return result, true + return arm64PoolInterval{}, false } diff --git a/arm64_raw_pool_carried_test.go b/arm64_raw_pool_carried_test.go index f37ea8105..622c98cb4 100644 --- a/arm64_raw_pool_carried_test.go +++ b/arm64_raw_pool_carried_test.go @@ -1,6 +1,76 @@ package plan9asm -import "testing" +import ( + "fmt" + "testing" +) + +func TestARM64PoolCarriedStridedAddress(t *testing.T) { + for _, step := range []uint64{1, 2, 4, 8, 16} { + for _, ascending := range []bool{false, true} { + for _, pointerIncreasing := range []bool{false, true} { + name := fmt.Sprintf("step=%d/ascending=%v/pointerIncreasing=%v", step, ascending, pointerIncreasing) + t.Run(name, func(t *testing.T) { + lines := []string{fmt.Sprintf("and x1,x0,#%d", step), fmt.Sprintf("add x1,x1,#%d", step), + "lsl x3,x1,#3", "add x3,x3,#32"} + pointerDelta := -int64(step * 8) + want := arm64PoolInterval{32 + step*8, 32 + step*16} + if pointerIncreasing { + lines = append(lines, "mov x4,#512", "sub x3,x4,x3") + pointerDelta, want = -pointerDelta, arm64PoolInterval{512 - want.high, 512 - want.low} + } + update := fmt.Sprintf("sub x1,x1,#%d", step) + if ascending { + lines = append(lines, "neg x1,x1") + update = fmt.Sprintf("add x1,x1,#%d", step) + } + head := len(lines) + lines = append(lines, fmt.Sprintf("ldr x4,[x3],#%d", pointerDelta), update, "cbnz x1,#-8", "ret") + flow := arm64PoolTestFlow(t, lines) + if got := flow.affineInterval(head, arm64PoolRegisterExpression(3)); got != want { + t.Fatalf("carried address=%+v, want %+v", got, want) + } + }) + } + } + } +} + +func TestARM64PoolCarriedStrideRejectsUnprovedRelation(t *testing.T) { + for _, change := range []string{ + "sub x3,x3,#7", // Non-integral address/counter ratio. + "add x3,x3,x5", // Unknown per-iteration delta. + "ldr x3,[x5]", // No affine recurrence. + } { + flow := arm64PoolTestFlow(t, []string{ + "and x1,x0,#8", "add x1,x1,#8", "lsl x3,x1,#3", + "nop", change, "sub x1,x1,#8", "cbnz x1,#-12", "ret", + }) + if got := flow.affineInterval(3, arm64PoolRegisterExpression(3)); got != arm64PoolUnknownInterval { + t.Fatalf("%s invented a carried bound: %+v", change, got) + } + } +} + +func TestARM64PoolCarriedInvariantAcrossUnsignedZero(t *testing.T) { + for _, ascending := range []bool{false, true} { + lines := []string{"and x1,x0,#8", "add x1,x1,#8", "and x4,x0,#7", + "add x3,x1,x4", "sub x3,x3,#4"} + update := "sub x1,x1,#8" + if ascending { + lines = append(lines, "neg x1,x1") + update = "add x1,x1,#8" + } + head := len(lines) + lines = append(lines, "ldr x5,[x3],#-8", update, "cbnz x1,#-8", "ret") + flow := arm64PoolTestFlow(t, lines) + // The invariant is x4-4, spanning [-4,3], but every actual address + // remains in [4,19]. Recenter the invariant; never clamp a wrapped span. + if got := flow.affineInterval(head, arm64PoolRegisterExpression(3)); got != (arm64PoolInterval{4, 19}) { + t.Fatalf("ascending=%v: carried bound=%+v, want [4,19]", ascending, got) + } + } +} func TestARM64PoolCarriedAddressCounterRelation(t *testing.T) { for _, test := range []struct { diff --git a/arm64_raw_pool_constant_compare_test.go b/arm64_raw_pool_constant_compare_test.go new file mode 100644 index 000000000..7cefb7733 --- /dev/null +++ b/arm64_raw_pool_constant_compare_test.go @@ -0,0 +1,103 @@ +package plan9asm + +import ( + "fmt" + "math" + "testing" +) + +func TestARM64PoolConstantRegisterCompareGuards(t *testing.T) { + for _, operand := range []struct { + text string + input uint64 + limit uint64 + }{ + {"x2", 15, 15}, + {"x2,lsl #1", 15, 30}, + {"x2,lsr #1", 30, 15}, + {"x2,asr #1", math.MaxUint64 - 29, math.MaxUint64 - 14}, + {"w2,uxtb #1", 0xff0f, 30}, + {"w2,uxth #1", 0xff0f, 0x1fe1e}, + {"w2,uxtw #1", 15, 30}, + {"x2,uxtx #1", 15, 30}, + {"w2,sxtb #1", 0xff, math.MaxUint64 - 1}, + {"w2,sxth #1", 0xffff, math.MaxUint64 - 1}, + {"w2,sxtw #1", math.MaxUint64, math.MaxUint64 - 1}, + {"x2,sxtx #1", math.MaxUint64, math.MaxUint64 - 1}, + } { + for _, condition := range []string{"hs", "lo", "hi", "ls"} { + t.Run(operand.text+"/"+condition, func(t *testing.T) { + flow := arm64PoolTestFlow(t, []string{ + fmt.Sprintf("mov x2,#%#x", operand.input), "cmp x1," + operand.text, + "b." + condition + " #8", "nop", "ret", + }) + constraint, ok := flow.affineEdgeConstraint(arm64RawPoolEdge{2, 4}) + want := arm64PoolUnknownInterval + switch condition { + case "hs": + want.low = operand.limit + case "lo": + want.high = operand.limit - 1 + case "hi": + want.low = operand.limit + 1 + case "ls": + want.high = operand.limit + } + if !ok || constraint.expression != arm64PoolRegisterExpression(1) || constraint.interval != want { + t.Fatalf("guard=%+v/%v, want R1 in %+v", constraint, ok, want) + } + }) + } + } +} + +func TestARM64PoolConstantCompareRejectsUnprovedLimits(t *testing.T) { + for _, lines := range [][]string{ + {"nop", "cmp x1,x2", "b.hs #8", "nop", "ret"}, + {"mov x2,#15", "cmp w1,w2", "b.hs #8", "nop", "ret"}, + {"mov x2,#15", "cmp sp,x2", "b.hs #8", "nop", "ret"}, + {"adr x2,#16", "cmp x1,x2", "b.hs #8", "nop", "ret"}, + } { + flow := arm64PoolTestFlow(t, lines) + flow.poolOrigins = map[int]uint64{0: 16} + if constraint, ok := flow.affineEdgeConstraint(arm64RawPoolEdge{2, 4}); ok { + t.Fatalf("%v invented numeric limit: %+v", lines, constraint) + } + } + flow := arm64PoolTestFlow(t, []string{"mov x2,#15", "cmp x1,x2", "mov x2,#7", "b.hs #8", "nop", "ret"}) + if constraint, ok := flow.affineEdgeConstraint(arm64RawPoolEdge{3, 5}); !ok || constraint.interval.low != 15 { + t.Fatalf("comparison used its overwritten limit: %+v/%v", constraint, ok) + } +} + +func TestARM64PoolConstantRegisterCMNGuards(t *testing.T) { + for _, condition := range []string{"hs", "lo", "hi", "ls"} { + flow := arm64PoolTestFlow(t, []string{"mov x2,#15", "cmn x1,x2,lsl #1", "b." + condition + " #8", "nop", "ret"}) + constraint, ok := flow.affineEdgeConstraint(arm64RawPoolEdge{2, 4}) + threshold := uint64(math.MaxUint64 - 29) + want := arm64PoolUnknownInterval + switch condition { + case "hs": + want.low = threshold + case "lo": + want.high = threshold - 1 + case "hi": + want.low = threshold + 1 + case "ls": + want.high = threshold + } + if !ok || constraint.expression != arm64PoolRegisterExpression(1) || constraint.interval != want { + t.Fatalf("CMN %s: %+v/%v, want %+v", condition, constraint, ok, want) + } + } + for _, test := range []struct{ setup, compare, branch string }{ + {"mov x2,#-1", "cmp x1,x2", "b.hi #8"}, + {"mov x2,#0", "cmp x1,x2", "b.lo #8"}, + {"mov x2,#1", "cmn x1,x2", "b.hi #8"}, + } { + flow := arm64PoolTestFlow(t, []string{test.setup, test.compare, test.branch, "nop", "ret"}) + if guard, ok := flow.affineEdgeConstraint(arm64RawPoolEdge{2, 4}); ok && guard.interval != arm64PoolUnknownInterval { + t.Fatalf("impossible comparison wrapped to a live bound: %+v", guard) + } + } +} diff --git a/arm64_raw_pool_flags.go b/arm64_raw_pool_flags.go index 30d707c7b..3f1a01a67 100644 --- a/arm64_raw_pool_flags.go +++ b/arm64_raw_pool_flags.go @@ -71,14 +71,32 @@ func (flow *arm64RawPoolValues) affineFlagSourceBefore(at int) (uint32, uint32, return 0, 0, 0, false } -// These complete vector families preserve NZCV. Predicate logical operations +// These complete scalable families preserve NZCV. Predicate logical operations // instead share the lowerer's explicit flag bit; their S variants cannot carry // an earlier scalar comparison. In particular, no-GP-output is insufficient: // SVE comparisons, WHILE*, PTEST and unknown instructions remain barriers. func arm64RawPoolSVEPreservesNZCV(word uint32) bool { + if _, ok := decodeARM64RawSVEAddress(word); ok { + return true + } + if _, ok := decodeARM64RawSVECnt(word); ok { + return true + } if _, ok := decodeARM64RawSVEEOR(word); ok { return true } + if _, ok := decodeARM64RawSVEDupImmediate(word); ok { + return true + } + if _, ok := decodeARM64RawSVEDupElement(word); ok { + return true + } + if _, ok := decodeARM64RawSVEDupGeneral(word); ok { + return true + } + if _, ok := decodeARM64RawSVECopy(word); ok { + return true + } if _, ok := decodeARM64RawSVEUnpack(word); ok { return true } diff --git a/arm64_raw_pool_impossible_compare.go b/arm64_raw_pool_impossible_compare.go new file mode 100644 index 000000000..0d3ee23db --- /dev/null +++ b/arm64_raw_pool_impossible_compare.go @@ -0,0 +1,34 @@ +package plan9asm + +// Remove only edges whose independently bounded incoming value cannot satisfy +// the unchanged CMP/CMN condition. This is proof-graph refinement, not deletion +// of executable branches. In particular, a larger VL can make an entire SVE +// path unreachable even though its unconstrained pool offsets do not fit. +func (flow *arm64RawPoolValues) refineConstantCompareEdges() { + for at, word := range flow.words { + if word&0xff000010 != 0x54000000 { + continue + } + switch word & 15 { + case 2, 3, 8, 9: + default: + continue + } + target := at + int(int32(word<<8)>>13) + for _, next := range []int{at + 1, target} { + if next < 0 || next >= len(flow.before) { + continue + } + edge := arm64RawPoolEdge{at, next} + guard, ok := flow.affineEdgeConstraint(edge) + if !ok || guard.after != 0 || guard.mask != 0 { + continue + } + numeric := flow.numericValues() + input := numeric.affineInterval(at, guard.expression) + if input.high < guard.interval.low || input.low > guard.interval.high { + flow.excludeEdge(edge) + } + } + } +} diff --git a/arm64_raw_pool_impossible_compare_test.go b/arm64_raw_pool_impossible_compare_test.go new file mode 100644 index 000000000..93eb4f717 --- /dev/null +++ b/arm64_raw_pool_impossible_compare_test.go @@ -0,0 +1,44 @@ +package plan9asm + +import "testing" + +func TestARM64PoolImpossibleConstantCompareEdges(t *testing.T) { + for _, test := range []struct { + compare, branch string + limit string + }{ + {"cmp x1,x2", "b.hs #12", "32"}, + {"cmp x1,x2", "b.hi #12", "31"}, + {"cmp x1,x2", "b.lo #12", "16"}, + {"cmp x1,x2", "b.ls #12", "15"}, + {"cmp x1,#32", "b.hs #12", "0"}, + {"cmn x1,x2", "b.hs #12", "15"}, + } { + flow := arm64PoolTestFlow(t, []string{ + "and x1,x0,#3", "add x1,x1,#16", "mov x2,#" + test.limit, + test.compare, test.branch, "mov x3,#7", "b #8", "mov x3,#99", "ret", + }) + if !flow.excluded[arm64RawPoolEdge{4, 7}] { + t.Fatalf("%s/%s did not exclude its impossible edge", test.compare, test.branch) + } + if got := flow.affineInterval(8, arm64PoolRegisterExpression(3)); got != (arm64PoolInterval{7, 7}) { + t.Fatalf("impossible path polluted the joined value: %+v", got) + } + } +} + +func TestARM64PoolCompareEdgesRemainWhenUnproved(t *testing.T) { + for _, setup := range [][]string{ + {"nop", "mov x2,#32", "cmp x1,x2", "nop"}, + {"mov x1,#16", "nop", "cmp x1,x2", "nop"}, + {"mov x1,#16", "mov x2,#32", "cmp x1,x2", "cmp x0,#0"}, + {"mov x1,#16", "mov x2,#32", "cmp x1,x2", "mov x1,#99"}, + {"mov x1,#16", "cbz x0,#12", "cmp x1,#32", "nop"}, + } { + lines := append(append([]string(nil), setup...), "b.hs #12", "mov x3,#7", "b #8", "mov x3,#99", "ret") + flow := arm64PoolTestFlow(t, lines) + if flow.excluded[arm64RawPoolEdge{4, 7}] { + t.Fatalf("unproved/bypassed/overwritten comparison excluded an edge: %v", setup) + } + } +} diff --git a/arm64_raw_pool_logical.go b/arm64_raw_pool_logical.go index a7b37276e..26d2b7379 100644 --- a/arm64_raw_pool_logical.go +++ b/arm64_raw_pool_logical.go @@ -10,7 +10,8 @@ import ( // Logical operations already have lowering for the complete Go grammar. This // proof layer recognizes when OR/XOR also preserve an affine relationship: // operands with disjoint possible one-bits cannot carry, so OR/XOR equal ADD. -// Other cases retain conservative bit bounds, never an invented ADD identity. +// AND/BIC can subtract bits proved constant across the input interval. Other +// cases retain conservative bit bounds, never an invented affine identity. type arm64PoolLogicalOperand struct { must, may uint64 value arm64PoolAffine @@ -24,7 +25,7 @@ func arm64PoolIntervalBits(interval arm64PoolInterval) (must, may uint64) { // A pool-relative offset is not the numeric address's bit pattern. Logical // proofs must use ordinary integer definitions even inside a relocation proof. -func (flow *arm64RawPoolValues) integerInterval(at int, reg arm64asm.Reg) arm64PoolInterval { +func (flow *arm64RawPoolValues) numericValues() *arm64RawPoolValues { numeric := flow if len(flow.poolOrigins) != 0 { copy := *flow @@ -37,6 +38,11 @@ func (flow *arm64RawPoolValues) integerInterval(at int, reg arm64asm.Reg) arm64P } numeric = © } + return numeric +} + +func (flow *arm64RawPoolValues) integerInterval(at int, reg arm64asm.Reg) arm64PoolInterval { + numeric := flow.numericValues() expression := arm64PoolRegisterExpression(int(reg - arm64asm.X0)) interval := numeric.invariantInterval(at, expression) if interval == arm64PoolUnknownInterval { @@ -95,7 +101,7 @@ func (flow *arm64RawPoolValues) affineLogicalDefinition(at int, word uint32) (ar binary.LittleEndian.PutUint32(code[:], word) ins, err := arm64asm.Decode(code[:]) if err != nil || word>>31 == 0 || word&31 == 31 || - (ins.Op != arm64asm.ORR && ins.Op != arm64asm.EOR) { + (ins.Op != arm64asm.ORR && ins.Op != arm64asm.EOR && ins.Op != arm64asm.AND && ins.Op != arm64asm.BIC) { return arm64PoolAffine{}, arm64PoolInterval{}, false, false } // The decoder validates the complete logical-immediate/shifted-register @@ -116,11 +122,45 @@ func (flow *arm64RawPoolValues) affineLogicalDefinition(at int, word uint32) (ar right = arm64PoolLogicalOperand{ must: value, may: value, value: arm64PoolAffine{constant: value}, affine: true, } - case word&0x1f200000 == 0x0a000000: + case word&0x1f000000 == 0x0a000000: right = flow.logicalRegister(at, int(word>>16&31)).shifted(word>>22&3, word>>10&63) default: return arm64PoolAffine{}, arm64PoolInterval{}, false, false } + if ins.Op == arm64asm.AND || ins.Op == arm64asm.BIC { + if ins.Op == arm64asm.BIC { + right.must, right.may = ^right.may, ^right.must + right.affine = false + } + interval := arm64PoolInterval{left.must & right.must, left.may & right.may} + if interval.low == interval.high { + return arm64PoolAffine{constant: interval.low}, interval, true, true + } + if value, ok := left.maskedAffine(right); ok { + return value, interval, true, true + } + if value, ok := right.maskedAffine(left); ok { + return value, interval, true, true + } + return arm64PoolAffine{}, interval, false, true + } + if overlap := left.may & right.may; overlap != 0 && left.affine && right.affine { + // An interval loses alignment bits: [8,16] includes odd numbers even + // when the actual values came from n&24. Independently prove just the + // low-bit residue needed for disjointness, using the existing bounded + // arithmetic/mask grammar. Never infer alignment from the endpoints. + width := bits.Len64(overlap) + if width <= 30 { + step := uint64(1) << uint(width) + numeric := flow.numericValues() + if numeric.multipleOfPowerOfTwo(at, right.value, step) { + right.may &^= step - 1 + } else if numeric.multipleOfPowerOfTwo(at, left.value, step) { + left.may &^= step - 1 + } + flow.affineWork = numeric.affineWork + } + } interval := arm64PoolInterval{left.must | right.must, left.may | right.may} if ins.Op == arm64asm.EOR { interval.low = left.must&^right.may | right.must&^left.may @@ -133,3 +173,18 @@ func (flow *arm64RawPoolValues) affineLogicalDefinition(at int, word uint32) (ar affine := left.affine && right.affine && left.may&right.may == 0 && value.add(right.value, 1) return value, interval, affine, true } + +func (operand arm64PoolLogicalOperand) maskedAffine(mask arm64PoolLogicalOperand) (arm64PoolAffine, bool) { + if !operand.affine || mask.must != mask.may { + return arm64PoolAffine{}, false + } + removed := operand.must &^ mask.must + if removed != operand.may&^mask.must { + return arm64PoolAffine{}, false + } + // Clearing fixed one-bits is subtraction without a borrow. This preserves + // the relation to the original input, including modular high-bit values. + value := operand.value + value.constant -= removed + return value, true +} diff --git a/arm64_raw_pool_logical_test.go b/arm64_raw_pool_logical_test.go index fa98fa3e3..eec01a57c 100644 --- a/arm64_raw_pool_logical_test.go +++ b/arm64_raw_pool_logical_test.go @@ -66,6 +66,57 @@ func TestARM64PoolLogicalBoundsDoNotInventRelations(t *testing.T) { } } +func TestARM64PoolLogicalDisjointAlignmentRelation(t *testing.T) { + for _, op := range []string{"orr", "eor"} { + for _, operands := range []string{"x1,x2", "x2,x1"} { + line := op + " x3," + operands + t.Run(line, func(t *testing.T) { + flow := arm64PoolTestFlow(t, []string{ + "and x1,x0,#1", "and x2,x0,#24", line, "ret", + }) + query := arm64PoolRegisterExpression(3) + query.add(arm64PoolRegisterExpression(1), -1) + query.add(arm64PoolRegisterExpression(2), -1) + if got := flow.affineInterval(3, query); got != (arm64PoolInterval{0, 0}) { + t.Fatalf("disjoint variable low/aligned bits lost their sum relation: %+v", got) + } + }) + } + } +} + +func TestARM64PoolLogicalAlignmentProofBoundaries(t *testing.T) { + for _, op := range []string{"orr", "eor"} { + for _, test := range []struct { + name, operand string + definitions []string + want bool + }{ + {"mask-copy", "x2", []string{"and x4,x0,#24", "mov x2,x4"}, true}, + {"mask-bic", "x2", []string{"mov x4,#-25", "bic x2,x0,x4"}, true}, + {"aligned-add", "x2", []string{"and x2,x0,#24", "add x2,x2,#8"}, true}, + {"aligned-subtract-wrap", "x2", []string{"and x2,x0,#24", "sub x2,x2,#8"}, true}, + {"shifted-alignment", "x2,lsl #1", []string{"and x2,x0,#12"}, true}, + {"unaligned-add", "x2", []string{"and x2,x0,#24", "add x2,x2,#1"}, false}, + {"overlapping-mask", "x2", []string{"mov x4,#25", "and x2,x0,x4"}, false}, + {"unknown-load", "x2", []string{"ldr x2,[x4]"}, false}, + {"unaligned-join", "x2", []string{"and x2,x0,#24", "cbz x4,#8", "add x2,x2,#1"}, false}, + {"non-affine-right-shift", "x2,lsr #3", []string{"and x2,x0,#24"}, false}, + } { + t.Run(op+"/"+test.name, func(t *testing.T) { + lines := append([]string{"and x1,x0,#1"}, test.definitions...) + at := len(lines) + lines = append(lines, op+" x3,x1,"+test.operand, "ret") + flow := arm64PoolTestFlow(t, lines) + _, _, affine, known := flow.affineLogicalDefinition(at, flow.words[at]) + if !known || affine != test.want { + t.Fatalf("alignment relation=%v known=%v, want %v", affine, known, test.want) + } + }) + } + } +} + func TestARM64PoolLogicalBitsAreNotRelocatedOffsets(t *testing.T) { for _, test := range []struct { line string diff --git a/arm64_raw_pool_loop.go b/arm64_raw_pool_loop.go index fcaa8e875..9a3bd7c8b 100644 --- a/arm64_raw_pool_loop.go +++ b/arm64_raw_pool_loop.go @@ -44,6 +44,10 @@ func (flow *arm64RawPoolValues) excludeEdge(edge arm64RawPoolEdge) { } } flow.before[edge.to] = remaining + if latch, recorded := flow.loopLatches[edge.to]; recorded && latch == edge.from { + delete(flow.loopBounds, edge.to) + delete(flow.loopLatches, edge.to) + } flow.pruneUnreachablePredecessors() flow.clearValueCaches() } @@ -90,9 +94,11 @@ func (flow *arm64RawPoolValues) prepareControlFlow() { for pass := 0; pass < 3; pass++ { excluded, loops := len(flow.excluded), len(flow.loopBounds) flow.refineBitEdges() + flow.refineConstantCompareEdges() for latch := range flow.words { flow.proveCounterLoop(latch) flow.proveOrderedCounterLoop(latch) + flow.proveRelationalOneIterationLoop(latch) } flow.clearValueCaches() if excluded == len(flow.excluded) && loops == len(flow.loopBounds) { @@ -171,16 +177,20 @@ func (flow *arm64RawPoolValues) proveCounterLoop(latch int) { flow.excludeEdge(arm64RawPoolEdge{latch, head}) return } - // A unit countdown from a positive unsigned value cannot wrap before - // reaching zero. At the loop head it stays in [1, initial.high]. Larger - // steps require a divisibility proof and are deliberately not inferred. + // Non-unit power-of-two strides additionally require a residue proof; + // a contiguous interval alone cannot establish that zero is reached. + bound := arm64PoolInterval{1, initial.high} if delta != ^uint64(0) || initial.low == 0 || initial == arm64PoolUnknownInterval { - return + var proved bool + bound, proved = entry.counterStrideBound(head, expression, initial, delta) + if !proved { + return + } } if flow.loopBounds == nil { flow.loopBounds = make(map[int]arm64PoolConstraint) } - flow.loopBounds[head] = arm64PoolConstraint{expression: expression, interval: arm64PoolInterval{1, initial.high}} + flow.loopBounds[head] = arm64PoolConstraint{expression: expression, interval: bound} flow.recordLoopLatch(head, latch) flow.clearValueCaches() } @@ -193,29 +203,14 @@ func (flow *arm64RawPoolValues) recordLoopLatch(head, latch int) { } func (flow *arm64RawPoolValues) counterLoopUpdate(head, latch, register int) (int, uint64, bool) { - if register >= 31 || head < 0 || head >= latch || latch-head > 512 { + if register >= 31 || !flow.straightLineLoopBody(head, latch) { return 0, 0, false } - for at := head + 1; at <= latch; at++ { - if len(flow.before[at]) != 1 || flow.before[at][0] != at-1 { - return 0, 0, false - } - } update := -1 var delta uint64 for at := head; at < latch; at++ { current := flow.words[at] - // The body must fall through only, including instructions that would - // otherwise appear harmless in the GP destination-effect classifier. - if current&0x7c000000 == 0x14000000 || current&0x7e000000 == 0x34000000 || - current&0x7e000000 == 0x36000000 || current&0xff000010 == 0x54000000 || - current&0xfe000000 == 0xd6000000 { - return 0, 0, false - } - writes, known := arm64RawPoolGPWrites(current) - if !known { - return 0, 0, false - } + writes, _ := arm64RawPoolGPWrites(current) if writes&(1<= latch || latch-head > 512 { + return false + } + for at := head + 1; at <= latch; at++ { + if len(flow.before[at]) != 1 || flow.before[at][0] != at-1 { + return false + } + } + for at := head; at < latch; at++ { + word := flow.words[at] + if word&0x7c000000 == 0x14000000 || word&0x7e000000 == 0x34000000 || + word&0x7e000000 == 0x36000000 || word&0xff000010 == 0x54000000 || + word&0xfe000000 == 0xd6000000 { + return false + } + if _, known := arm64RawPoolGPWrites(word); !known { + return false + } + } + return true +} + func (flow *arm64RawPoolValues) counterLoopEntry(head, latch int) *arm64RawPoolValues { // Infer the first iteration from external entry paths only. A prior body // execution reached through an enclosing loop is opaque: deleting a @@ -243,6 +261,15 @@ func (flow *arm64RawPoolValues) counterLoopEntry(head, latch int) *arm64RawPoolV opaqueLoops: map[int]int{latch: head}, } entry.clearValueCaches() + // Only already-proved, structurally earlier loops can help establish this + // loop's entry values. Never use this loop or an enclosing loop as its + // own induction premise. Carried-invariant entry proofs disable these + // summaries again, keeping that analysis non-recursive. + for previousHead, previousLatch := range flow.loopLatches { + if previousLatch < head { + entry.recordLoopLatch(previousHead, previousLatch) + } + } entry.before[head] = nil for _, previous := range flow.before[head] { if previous == latch { diff --git a/arm64_raw_pool_loop_test.go b/arm64_raw_pool_loop_test.go index 18038ec42..5d72e2e30 100644 --- a/arm64_raw_pool_loop_test.go +++ b/arm64_raw_pool_loop_test.go @@ -70,6 +70,22 @@ func TestARM64PoolCounterLoops(t *testing.T) { } } +func TestARM64PoolExcludedBackedgeDropsInductionMetadata(t *testing.T) { + flow := arm64PoolTestFlow(t, []string{ + "and x1,x0,#8", "add x1,x1,#8", "nop", "sub x1,x1,#8", "cbnz x1,#-8", "ret", + }) + if _, proved := flow.loopBounds[2]; !proved { + t.Fatal("fixture did not establish a counter loop") + } + flow.excludeEdge(arm64RawPoolEdge{4, 2}) + if _, retained := flow.loopBounds[2]; retained { + t.Fatal("excluded backedge retained its induction constraint") + } + if _, retained := flow.loopLatches[2]; retained { + t.Fatal("excluded backedge retained its carried-value latch") + } +} + func TestARM64PoolImpossibleBitEdge(t *testing.T) { // The backwards TBNZ edge is impossible for 16..19, but its MOV would // otherwise pollute the joined result before the query becomes constant. diff --git a/arm64_raw_pool_mask_difference_test.go b/arm64_raw_pool_mask_difference_test.go new file mode 100644 index 000000000..233505c6f --- /dev/null +++ b/arm64_raw_pool_mask_difference_test.go @@ -0,0 +1,59 @@ +package plan9asm + +import "testing" + +func TestARM64PoolMaskedDifference(t *testing.T) { + for _, test := range []struct { + setup, mask string + }{ + {"mov x2,#24", "and x3,x1,#24"}, + {"mov x2,#24", "ands x3,x1,#24"}, + {"mov x2,#24", "and x3,x1,x2"}, + {"mov x2,#24", "ands x3,x2,x1"}, + {"mov x2,#-25", "bic x3,x1,x2"}, + {"mov x2,#-25", "bics x3,x1,x2"}, + {"mov x2,#12", "and x3,x1,x2,lsl #1"}, + {"mov x2,#48", "and x3,x1,x2,lsr #1"}, + {"mov x2,#48", "and x3,x1,x2,asr #1"}, + {"mov x2,#12", "and x3,x1,x2,ror #63"}, + } { + t.Run(test.mask, func(t *testing.T) { + flow := arm64PoolTestFlow(t, []string{ + "and x4,x0,#15", "add x4,x4,#8", "mov x1,x4", test.setup, test.mask, "ret", + }) + for _, source := range []int{1, 4} { + query := arm64PoolRegisterExpression(source) + query.add(arm64PoolRegisterExpression(3), -1) + if got := flow.affineInterval(5, query); got != (arm64PoolInterval{0, 7}) { + t.Errorf("source=x%d, masked difference=%+v, want [0,7]", source, got) + } + } + }) + } +} + +func TestARM64PoolMaskedDifferenceRejectsUnrelatedValues(t *testing.T) { + for _, mask := range []string{"and x3,x1,x2", "and w3,w1,#24"} { + flow := arm64PoolTestFlow(t, []string{mask, "ret"}) + query := arm64PoolRegisterExpression(4) + query.add(arm64PoolRegisterExpression(3), -1) + if got := flow.affineInterval(1, query); got != arm64PoolUnknownInterval { + t.Fatalf("%s: unrelated difference bounded as %+v", mask, got) + } + } +} + +func TestARM64PoolMaskedDifferenceIntersectsNonzeroGuard(t *testing.T) { + flow := arm64PoolTestFlow(t, []string{ + "and x1,x0,#15", "add x1,x1,#8", "and x3,x1,#24", + "cmp x1,x3", "b.eq #12", "sub x4,x1,x3", "nop", "ret", + }) + if got := flow.affineInterval(6, arm64PoolRegisterExpression(4)); got != (arm64PoolInterval{1, 7}) { + t.Fatalf("nonzero masked difference=%+v, want [1,7]", got) + } + query := arm64PoolRegisterExpression(4) + query.constant = ^uint64(0) + if got := flow.affineInterval(6, query); got != (arm64PoolInterval{0, 6}) { + t.Fatalf("nonzero masked difference minus one=%+v, want [0,6]", got) + } +} diff --git a/arm64_raw_pool_mask_relation.go b/arm64_raw_pool_mask_relation.go new file mode 100644 index 000000000..5e9e8fcc5 --- /dev/null +++ b/arm64_raw_pool_mask_relation.go @@ -0,0 +1,116 @@ +package plan9asm + +import ( + "encoding/binary" + "math" + + "golang.org/x/arch/arm64/arm64asm" +) + +type arm64PoolMaskedDefinition struct { + destination, source int + mask uint64 +} + +// Normalize immediate and shifted-register AND/BIC, including flag-setting +// forms, only after typed decoding. Both residue and difference proofs consume +// this same definition; an unknown register mask is not a constant. +func (flow *arm64RawPoolValues) maskedConstantDefinition(at int, word uint32) (arm64PoolMaskedDefinition, bool) { + var code [4]byte + binary.LittleEndian.PutUint32(code[:], word) + ins, err := arm64asm.Decode(code[:]) + if err != nil || word>>31 == 0 || word&31 == 31 || + (ins.Op != arm64asm.AND && ins.Op != arm64asm.ANDS && ins.Op != arm64asm.BIC && ins.Op != arm64asm.BICS) { + return arm64PoolMaskedDefinition{}, false + } + var mask uint64 + source := int(word >> 5 & 31) + switch { + case word&0x1f800000 == 0x12000000: + switch immediate := ins.Args[2].(type) { + case arm64asm.Imm: + mask = uint64(immediate.Imm) + case arm64asm.Imm64: + mask = immediate.Imm + default: + return arm64PoolMaskedDefinition{}, false + } + case word&0x1f000000 == 0x0a000000: + value, known := flow.independentMaskConstant(at, int(word>>16&31)) + if !known { + // Unshifted AND is commutative; BIC and a shifted variable source + // cannot be exchanged with the constant mask in this grammar. + if (ins.Op != arm64asm.AND && ins.Op != arm64asm.ANDS) || word>>10&63 != 0 { + return arm64PoolMaskedDefinition{}, false + } + value, known = flow.independentMaskConstant(at, source) + if !known { + return arm64PoolMaskedDefinition{}, false + } + source = int(word >> 16 & 31) + } + operand := arm64PoolLogicalOperand{must: value, may: value}.shifted(word>>22&3, word>>10&63) + mask = operand.must + default: + return arm64PoolMaskedDefinition{}, false + } + if ins.Op == arm64asm.BIC || ins.Op == arm64asm.BICS { + mask = ^mask + } + return arm64PoolMaskedDefinition{int(word & 31), source, mask}, true +} + +func (flow *arm64RawPoolValues) independentMaskConstant(at, register int) (uint64, bool) { + // Preserve the enclosing budget and never interpret a pool-relative + // offset as the mask's numeric bit pattern. + numeric := *flow.numericValues() + numeric.clearValueCaches() + value := numeric.invariantInterval(at, arm64PoolRegisterExpression(register)) + flow.affineWork += numeric.affineWork + return value.low, value.low == value.high +} + +// n-(n&mask) equals n&^mask without borrow. Retain that relation even when the +// removed bits vary. The source must survive the write; an in-place mask's old +// source cannot be confused with its new destination. +func (flow *arm64RawPoolValues) maskedDifferenceBound(at int, word uint32, query arm64PoolAffine, constraints []arm64PoolConstraint) (arm64PoolInterval, bool) { + definition, ok := flow.maskedConstantDefinition(at, word) + if !ok || definition.source == definition.destination { + return arm64PoolInterval{}, false + } + scale := -query.coefficient[definition.destination] + if scale == 0 { + return arm64PoolInterval{}, false + } + relation := arm64PoolRegisterExpression(definition.source) + relation.add(arm64PoolRegisterExpression(definition.destination), -1) + remainder := query + if !remainder.add(relation, -scale) { + return arm64PoolInterval{}, false + } + // The destination is now eliminated. Every remaining register has the + // same value before and after this instruction, so entry definitions are + // valid for the independent residual, including an aliased source. + residual := flow.invariantInterval(at, remainder) + if residual == arm64PoolUnknownInterval { + residual = flow.affineInterval(at, remainder) + } + if residual == arm64PoolUnknownInterval { + return arm64PoolInterval{}, false + } + input := flow.integerInterval(at, arm64asm.X0+arm64asm.Reg(definition.source)) + span := arm64PoolMaskInterval(input, ^definition.mask) + // Apply matching guards before a negative displacement can wrap the + // unsigned image, e.g. n-(n&mask) != 0 followed by subtracting one. + constraint := arm64PoolTightenConstraint(arm64PoolConstraint{expression: relation, interval: span}, constraints) + if constraint.interval.low > constraint.interval.high { + return constraint.interval, true + } + result := arm64PoolIntervalImage(constraint.interval, scale, residual.low) + width := residual.high - residual.low + if result.high > math.MaxUint64-width || flow.affineWork >= 16384 { + return arm64PoolInterval{}, false + } + result.high += width + return result, true +} diff --git a/arm64_raw_pool_masked_logical_test.go b/arm64_raw_pool_masked_logical_test.go new file mode 100644 index 000000000..ce21ab263 --- /dev/null +++ b/arm64_raw_pool_masked_logical_test.go @@ -0,0 +1,100 @@ +package plan9asm + +import ( + "fmt" + "math" + "testing" +) + +func TestARM64PoolMaskedLogicalRelations(t *testing.T) { + for _, test := range []struct { + line string + mask uint64 + }{ + {"and x3,x1,x2", 15}, + {"and x3,x2,x1", 15}, + {"and x3,x1,#15", 0}, + {"and x3,x1,x2,lsl #1", 7}, + {"and x3,x1,x2,lsr #1", 30}, + {"and x3,x1,x2,asr #1", 30}, + {"and x3,x1,x2,ror #63", 1<<63 | 7}, + {"bic x3,x1,x2", ^uint64(15)}, + {"bic x3,x1,x2,lsl #1", ^uint64(6)}, + {"bic x3,x1,x2,lsr #1", math.MaxUint64 - 31}, + {"bic x3,x1,x2,asr #1", math.MaxUint64 - 31}, + {"bic x3,x1,x2,ror #63", ^uint64(1<<63 | 7)}, + } { + t.Run(test.line, func(t *testing.T) { + flow := arm64PoolTestFlow(t, []string{ + "and x1,x0,#3", "add x1,x1,#16", + fmt.Sprintf("mov x2,#%#x", test.mask), test.line, "ret", + }) + value, span, affine, known := flow.affineLogicalDefinition(3, flow.words[3]) + if !known { + t.Fatalf("masked logical form was not modeled: %s", test.line) + } + // LSL produces a mask that drops one variable bit: it must retain + // bounds without pretending the result is an affine displacement. + shifted := test.line == "and x3,x1,x2,lsl #1" || test.line == "bic x3,x1,x2,lsl #1" + want := arm64PoolInterval{0, 3} + if shifted { + want.high = 2 + if test.line == "bic x3,x1,x2,lsl #1" { + want.high = 1 + } + } + if span != want || affine == shifted { + t.Fatalf("result=%+v affine=%v, want %+v affine=%v", span, affine, want, !shifted) + } + if affine { + expected := arm64PoolRegisterExpression(1) + expected.constant = math.MaxUint64 - 15 + if value != expected { + t.Fatalf("lost n & mask = n - 16 relationship: %+v", value) + } + } + }) + } +} + +func TestARM64PoolMaskedAffineConcreteValues(t *testing.T) { + for _, base := range []uint64{0, 16, 31, 1 << 63, math.MaxUint64 - 31} { + for width := uint64(0); width < 16; width++ { + must, may := arm64PoolIntervalBits(arm64PoolInterval{base, base + width}) + operand := arm64PoolLogicalOperand{ + must: must, may: may, value: arm64PoolRegisterExpression(1), affine: true, + } + for _, mask := range []uint64{0, 1, 7, 15, 16, 24, 255, 1 << 63, math.MaxUint64} { + value, affine := operand.maskedAffine(arm64PoolLogicalOperand{must: mask, may: mask}) + if !affine { + continue + } + for n := base; n <= base+width; n++ { + if got := uint64(value.coefficient[1])*n + value.constant; got != n&mask { + t.Fatalf("mask=%#x n=%#x produced %#x from %+v", mask, n, got, value) + } + } + } + } + } +} + +func TestARM64PoolMaskedLogicalUnknownAndRelocatedValues(t *testing.T) { + for _, op := range []string{"and", "bic"} { + flow := arm64PoolTestFlow(t, []string{op + " x3,x1,x2", "ret"}) + _, span, affine, known := flow.affineLogicalDefinition(0, flow.words[0]) + if !known || affine || span != arm64PoolUnknownInterval { + t.Fatalf("%s invented a relation for unknown operands: %+v/%v/%v", op, span, affine, known) + } + flow = arm64PoolTestFlow(t, []string{op + " w3,w1,w2", "ret"}) + if _, _, _, known := flow.affineLogicalDefinition(0, flow.words[0]); known { + t.Fatalf("%s truncating W form became an X relation", op) + } + } + flow := arm64PoolTestFlow(t, []string{"adr x1,#12", "mov x2,#15", "and x3,x1,x2", "ret"}) + flow.poolOrigins = map[int]uint64{0: 16} + _, span, affine, known := flow.affineLogicalDefinition(2, flow.words[2]) + if !known || affine || span != (arm64PoolInterval{0, 15}) { + t.Fatalf("pool offset became numeric address bits: %+v/%v/%v", span, affine, known) + } +} diff --git a/arm64_raw_pool_masked_runtime_test.go b/arm64_raw_pool_masked_runtime_test.go new file mode 100644 index 000000000..4e1526af0 --- /dev/null +++ b/arm64_raw_pool_masked_runtime_test.go @@ -0,0 +1,188 @@ +package plan9asm + +import ( + "fmt" + "runtime" + "strings" + "testing" +) + +func arm64RawPoolMaskedIR(t *testing.T, triple string) string { + t.Helper() + lines := []string{"adr x9,#0", "mov x2,#15", "mov x3,#-16", "mov x6,#16", "mov x7,#19"} + forms := []string{"and x4,x1,x2", "and x4,x2,x1", "bic x4,x1,x3", "and x4,x1,#15"} + for mode, limits := range [][2]string{{"#16", "#19"}, {"x6", "x7"}} { + for index, form := range forms { + lines = append(lines, "cmp x1,"+limits[0], "b.lo #32", "cmp x1,"+limits[1], "b.hi #24", form, + "sub x5,x1,x4", "sub x5,x5,#16", "ldr x5,[x9,x5]", fmt.Sprintf("str x5,[x0,#%d]", (mode*len(forms)+index)*8)) + } + } + for index, compare := range []string{"cmp x11,x4", "cmp x4,x11"} { + start := len(lines) + lines = append(lines, "cmp x1,#16", "b.lo #0", "cmp x1,#19", "b.hi #0", + "mov x10,x9", "mov x11,x1", "and x4,x1,x2") + head := len(lines) + // A second iteration would read beyond the twenty-four-byte pool. The + // relational zero proof must establish that the backedge is impossible. + lines = append(lines, "ldr x5,[x10],#24", "sub x11,x11,x6", compare) + lines = append(lines, fmt.Sprintf("b.ne #%d", (head-len(lines))*4), + fmt.Sprintf("str x5,[x0,#%d]", 64+index*8), "mov x10,xzr") + lines[start+1] = fmt.Sprintf("b.lo #%d", (len(lines)-start-1)*4) + lines[start+3] = fmt.Sprintf("b.hi #%d", (len(lines)-start-3)*4) + } + // The alternative load is genuinely out of bounds. Only the proved + // incoming 16..19 interval makes its branch impossible. + lines = append(lines, "and x12,x1,#3", "add x12,x12,#16", "mov x6,#32", + "cmp x12,x6", "b.hs #12", "ldr x5,[x9]", "b #8", "ldr x5,[x9,#24]", + "str x5,[x0,#80]") + for index := 0; index < 4; index++ { + ascending, carried := index&1 != 0, index&2 != 0 + lines = append(lines, "and x11,x1,#8", "add x11,x11,#8", "mov x12,#0") + copy, update := "mov x10,x11", "sub x11,x11,#8" + if ascending { + lines = append(lines, "neg x11,x11") + copy, update = "neg x10,x11", "add x11,x11,#8" + } + if carried { + lines = append(lines, copy, "sub x10,x10,#8", "add x10,x9,x10") + } + head := len(lines) + if carried { + lines = append(lines, "ldr x5,[x10],#-8") + } else { + lines = append(lines, copy, "sub x10,x10,#8", "ldr x5,[x9,x10]") + } + lines = append(lines, "add x12,x12,x5", update) + lines = append(lines, fmt.Sprintf("cbnz x11,#%d", (head-len(lines))*4), + fmt.Sprintf("str x12,[x0,#%d]", 88+index*8), "mov x10,xzr") + } + lines = append(lines, "mov x6,#-25") + for index, mask := range []string{"and x10,x11,#24", "ands x10,x11,#24", "bic x10,x11,x6", "bics x10,x11,x6"} { + lines = append(lines, "and x11,x1,#15", "add x11,x11,#8", mask, + "sub x10,x11,x10", "ldrb w5,[x9,x10]", fmt.Sprintf("str x5,[x0,#%d]", 120+index*8)) + } + for index, ascending := range []bool{false, true} { + lines = append(lines, "and x11,x1,#8", "add x11,x11,#8", "and x12,x1,#7", + "add x10,x11,x12", "sub x10,x10,#4", "add x10,x9,x10", "mov x12,#0") + update := "sub x11,x11,#8" + if ascending { + lines = append(lines, "neg x11,x11") + update = "add x11,x11,#8" + } + head := len(lines) + lines = append(lines, "ldrb w5,[x10],#-8", "add x12,x12,x5", update) + lines = append(lines, fmt.Sprintf("cbnz x11,#%d", (head-len(lines))*4), + fmt.Sprintf("str x12,[x0,#%d]", 152+index*8), "mov x10,xzr") + } + lines = append(lines, "and x11,x1,#15", "add x11,x11,#8", "and x10,x11,#24", + "cmp x11,x10", "b.eq #20", "sub x10,x11,x10", "sub x10,x10,#1", + "ldrb w5,[x9,x10]", "str x5,[x0,#168]") + // The earlier loop preserves a relation expressed using different copies + // of the length. Its guard excludes a zero remainder before the later + // countdown indexes the pool. The input is also an arbitrary base offset, + // including values for which base+length wraps at the machine width. + lines = append(lines, "and x17,x1,#15", "add x17,x17,#8", "mov x16,x1", + "add x14,x16,x17", "and x6,x17,#24", "neg x7,x6", + "nop", "add x7,x7,#8", "cbnz x7,#-8", "cmp x17,x6", + "b.eq #44", "sub x17,x14,x6", "add x6,x6,x16", "sub x16,x17,x16", "mov x12,#0", + "sub x7,x16,#1", "ldrb w5,[x9,x7]", "add x12,x12,x5", "subs x16,x16,#1", "b.ne #-16", + "str x12,[x0,#176]") + for index, op := range []string{"orr", "eor"} { + lines = append(lines, "and x11,x1,#15", "add x11,x11,#8", "and x10,x11,#24", + "and x12,x1,#1", op+" x10,x10,x12", "sub x10,x10,x12", "sub x10,x11,x10", + "ldrb w5,[x9,x10]", fmt.Sprintf("str x5,[x0,#%d]", 184+index*8)) + } + lines = append(lines, "mov x9,xzr", "ret") + lines[0] = fmt.Sprintf("adr x9,#%d", len(lines)*4) + var source strings.Builder + source.WriteString("TEXT pool_masked(SB),$0-16\nMOVD out+0(FP),R0\nMOVD input+8(FP),R1\n") + for _, word := range assembleARM64LLVMWords(t, lines, "") { + fmt.Fprintf(&source, "WORD $%#08x\n", word) + } + for index := 0; index < 6; index++ { + fmt.Fprintf(&source, "WORD $%#x\n", 0x17b4a140+index) + } + source.WriteString("RET\n") + requireARM64GoAssemblerResult(t, source.String(), true) + file, err := Parse(ArchARM64, source.String()) + if err != nil { + t.Fatal(err) + } + ir, err := Translate(file, Options{Goarch: "arm64", TargetTriple: triple, + Sigs: map[string]FuncSig{"pool_masked": { + Name: "pool_masked", Args: []LLVMType{Ptr, I64}, Ret: Void, + Frame: FrameLayout{Params: []FrameSlot{ + {Offset: 0, Type: Ptr, Index: 0, Field: -1}, + {Offset: 8, Type: I64, Index: 1, Field: -1}, + }}, + }}, + }) + if err != nil { + t.Fatal(err) + } + return ir +} + +func TestARM64RawPoolMaskedLLVM(t *testing.T) { + llc := findLLVM22Tool("llc") + if llc == "" { + t.Fatal("LLVM 22 llc not found") + } + for _, triple := range []string{"aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-pc-windows-msvc"} { + t.Run(triple, func(t *testing.T) { + ir := arm64RawPoolMaskedIR(t, triple) + compileLLVMToObject(t, llc, triple, "pool_masked.ll", "pool_masked.o", ir) + if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" && triple == "aarch64-apple-darwin" { + clang := findLLVM22Tool("clang") + if clang == "" { + t.Fatal("LLVM 22 clang not found") + } + compileAndRunRuntimeTestForTarget(t, llc, clang, "pool_masked", triple, ir, arm64RawPoolMaskedMain, nil) + } + }) + } +} + +const arm64RawPoolMaskedMain = ` +#include +#include +extern void pool_masked(uint64_t *, uint64_t); +int main(void) { + const uint64_t inputs[] = {0, 1, 15, 16, 17, 18, 19, 20, 31, 255, UINT64_C(1)<<63, UINT64_MAX}; + const uint64_t poolWords[] = {UINT64_C(0x17b4a14117b4a140), UINT64_C(0x17b4a14317b4a142), UINT64_C(0x17b4a14517b4a144)}; + uint8_t poolBytes[24]; + memcpy(poolBytes, poolWords, sizeof(poolBytes)); + for (unsigned test = 0; test < 32 + sizeof(inputs)/sizeof(inputs[0]); test++) { + uint64_t out[27] = {0x1234}; + out[26] = 0x5678; + uint64_t n = test < 32 ? test : inputs[test - 32]; + pool_masked(out+1, n); + if (out[0] != 0x1234 || out[26] != 0x5678) return 1; + uint64_t expected = n >= 16 && n <= 19 ? UINT64_C(0x17b4a14117b4a140) : 0; + for (unsigned form = 0; form < 10; form++) { + if (out[form+1] != expected) return 2; + } + if (out[11] != UINT64_C(0x17b4a14117b4a140)) return 3; + uint64_t sum = UINT64_C(0x17b4a14117b4a140); + if (n & 8) sum += UINT64_C(0x17b4a14317b4a142); + for (unsigned form = 12; form < 16; form++) { + if (out[form] != sum) return 4; + } + uint64_t remainderByte = (UINT64_C(0x17b4a14117b4a140) >> ((n & 7) * 8)) & 255; + for (unsigned form = 16; form < 20; form++) { + if (out[form] != remainderByte) return 5; + } + uint64_t byteSum = 0; + for (unsigned remaining = (n & 8) + 8; remaining; remaining -= 8) { + byteSum += poolBytes[remaining + (n & 7) - 4]; + } + if (out[20] != byteSum || out[21] != byteSum) return 6; + if (out[22] != ((n & 7) ? poolBytes[(n & 7) - 1] : 0)) return 7; + uint64_t tailSum = 0; + for (unsigned index = 0; index < (n & 7); index++) tailSum += poolBytes[index]; + if (out[23] != tailSum) return 8; + if (out[24] != poolBytes[n & 7] || out[25] != poolBytes[n & 7]) return 9; + } + return 0; +} +` diff --git a/arm64_raw_pool_negate_test.go b/arm64_raw_pool_negate_test.go new file mode 100644 index 000000000..7144317b1 --- /dev/null +++ b/arm64_raw_pool_negate_test.go @@ -0,0 +1,46 @@ +package plan9asm + +import ( + "fmt" + "testing" +) + +func TestARM64PoolAffineNegateAliases(t *testing.T) { + type example struct { + line string + value arm64PoolAffine + affine bool + } + var examples []example + for _, opcode := range []string{"neg", "negs"} { + for _, width := range []string{"x", "w"} { + for _, shift := range []string{"lsl", "lsr", "asr"} { + for _, amount := range []uint{0, 1, 30, 31, 63} { + if width == "w" && amount > 31 { + continue + } + line := fmt.Sprintf("%s %s1,%s2,%s #%d", opcode, width, width, shift, amount) + affine := width == "x" && shift == "lsl" && amount <= 30 + var value arm64PoolAffine + if affine { + value.coefficient[2] = -(int64(1) << amount) + } + examples = append(examples, example{line, value, affine}) + } + } + } + examples = append(examples, example{opcode + " x1,xzr", arm64PoolAffine{}, true}) + } + var lines []string + for _, test := range examples { + lines = append(lines, test.line) + } + words := assembleARM64LLVMWords(t, lines, "") + for at, test := range examples { + destination, value, affine := arm64PoolAffineDefinition(words[at]) + if affine != test.affine || affine && (destination != 1 || value != test.value) { + t.Errorf("%s: destination=%d value=%+v affine=%v, want %+v affine=%v", + test.line, destination, value, affine, test.value, test.affine) + } + } +} diff --git a/arm64_raw_pool_relational_loop.go b/arm64_raw_pool_relational_loop.go new file mode 100644 index 000000000..f5a4ad6d3 --- /dev/null +++ b/arm64_raw_pool_relational_loop.go @@ -0,0 +1,54 @@ +package plan9asm + +// Prove the first comparison is equal on every external entry. This excludes +// only the nonzero backedge; it does not infer a general multi-iteration range. +// Rewind the compared expression, not independent ranges for its operands. +func (flow *arm64RawPoolValues) proveRelationalOneIterationLoop(latch int) { + word := flow.words[latch] + if word&0xff00001f != 0x54000001 { + return + } + head := latch + int(int32(word<<8)>>13) + if flow.excluded[arm64RawPoolEdge{latch, head}] || !flow.straightLineLoopBody(head, latch) { + return + } + compare, _, after, known := flow.affineFlagSourceBefore(latch) + if !known || after <= head || after > latch { + return + } + _, expression, affine := flow.affineDefinition(compare &^ ((1 << 29) | 31)) + if !affine { + return + } + for at := after - 2; at >= head; at-- { + writes, _ := arm64RawPoolGPWrites(flow.words[at]) + if affected := writes & expression.registerMask(); affected != 0 { + destination, value, valid := flow.affineDefinition(flow.words[at]) + if !valid || affected != 1<= 1 && got.high <= 7 { + t.Fatalf("changed length/guard incorrectly retained the old remainder: %+v", got) + } + }) + } +} + +func TestARM64PoolInvariantLoopForgetsChangingConstraints(t *testing.T) { + flow := arm64PoolTestFlow(t, []string{ + "and x7,x0,#8", "add x7,x7,#8", "nop", "sub x7,x7,#8", "cbnz x7,#-8", "ret", + }) + stable := arm64PoolConstraint{expression: arm64PoolRegisterExpression(17), interval: arm64PoolInterval{1, 7}} + changing := arm64PoolConstraint{expression: arm64PoolRegisterExpression(7), interval: arm64PoolInterval{8, 8}} + historical := stable + historical.after = 1 + state := arm64PoolAffineState{ + at: 2, expression: arm64PoolRegisterExpression(17), + constraints: [8]arm64PoolConstraint{changing, historical, stable}, count: 3, + } + if latch, ok := flow.rewindInvariantLoop(&state); !ok || latch != 4 { + t.Fatalf("unchanged query not rewound: latch=%d ok=%v", latch, ok) + } + if state.count != 1 || state.constraints != ([8]arm64PoolConstraint{stable}) { + t.Fatalf("retained a changing or inactive constraint: %+v", state) + } + state.expression = arm64PoolRegisterExpression(7) + if _, ok := flow.rewindInvariantLoop(&state); ok { + t.Fatal("rewound a changing counter as invariant") + } + flow.loopLatches = nil + state.expression = arm64PoolRegisterExpression(17) + if _, ok := flow.rewindInvariantLoop(&state); ok { + t.Fatal("rewound an uncertified loop") + } +} + +func TestARM64PoolCounterAfterEarlierLoopPreservesRelationalGuard(t *testing.T) { + flow := arm64PoolTestFlow(t, []string{ + "and x14,x0,#15", "add x14,x14,#8", "mov x16,#0", "sub x17,x14,x16", + "and x6,x17,#24", "neg x7,x6", "nop", "add x7,x7,#8", "cbnz x7,#-8", + "cmp x17,x6", "b.eq #28", "sub x17,x14,x6", "add x6,x6,x16", + "sub x16,x17,x16", "nop", "subs x16,x16,#1", "b.ne #-8", "ret", + }) + if got := flow.affineInterval(14, arm64PoolRegisterExpression(16)); got != (arm64PoolInterval{1, 7}) { + t.Fatalf("tail counter with an earlier equivalent guard=%+v, want [1,7]", got) + } +} diff --git a/arm64_raw_pool_stride.go b/arm64_raw_pool_stride.go new file mode 100644 index 000000000..19c3ad51b --- /dev/null +++ b/arm64_raw_pool_stride.go @@ -0,0 +1,125 @@ +package plan9asm + +import "math" + +func (flow *arm64RawPoolValues) counterStrideBound(at int, expression arm64PoolAffine, initial arm64PoolInterval, delta uint64) (arm64PoolInterval, bool) { + signed := int64(delta) + if signed == 0 || signed < -(1<<30) || signed > 1<<30 { + return arm64PoolInterval{}, false + } + step := uint64(signed) + if signed < 0 { + step = -step + } + if step&(step-1) != 0 { + return arm64PoolInterval{}, false + } + if signed < 0 { + if initial.low == 0 || initial.high > math.MaxInt64 { + return arm64PoolInterval{}, false + } + } else { + if initial.low <= math.MaxInt64 { + return arm64PoolInterval{}, false + } + } + if !flow.multipleOfPowerOfTwo(at, expression, step) || initial.low > math.MaxUint64-(step-1) { + return arm64PoolInterval{}, false + } + // Refine the independently proved interval with the independently proved + // residue. For example [-19,-1] contains only -16 and -8 divisible by eight. + initial.low = (initial.low + step - 1) &^ (step - 1) + initial.high &^= step - 1 + if initial.low > initial.high { + return arm64PoolInterval{}, false + } + if signed < 0 { + return arm64PoolInterval{step, initial.high}, true + } + return arm64PoolInterval{initial.low, -step}, true +} + +// Retain only the residue through arithmetic definitions. In particular, +// -(n & 24) is divisible by eight even though its interval includes values +// between the two actual possibilities -16 and -8. No branch predicate is +// needed; every predecessor must establish residue zero independently. +func (flow *arm64RawPoolValues) multipleOfPowerOfTwo(at int, expression arm64PoolAffine, step uint64) bool { + mask := step - 1 + normalize := func(value arm64PoolAffine) arm64PoolAffine { + value.constant &= mask + for register, coefficient := range value.coefficient { + value.coefficient[register] = int64(uint64(coefficient) & mask) + } + return value + } + queue := []arm64PoolAffineQuery{{at, normalize(expression)}} + visited := make(map[arm64PoolAffineQuery]bool) + found := false + for work := 0; len(queue) > 0; work++ { + if work >= 4096 || flow.affineWork >= 16384 { + return false + } + flow.affineWork++ + state := queue[len(queue)-1] + queue = queue[:len(queue)-1] + if visited[state] { + continue + } + visited[state] = true + if state.expression.relocations != 0 { + return false + } + if state.expression.isConstant() { + if state.expression.constant != 0 { + return false + } + found = true + continue + } + if state.at < 0 || state.at >= len(flow.before) || len(flow.before[state.at]) == 0 { + return false + } + for _, previous := range flow.before[state.at] { + if previous < 0 || flow.opaque[previous] && !flow.opaquePreserves(previous, state.expression) { + return false + } + word := flow.words[previous] + writes, known := arm64RawPoolGPWrites(word) + if !known { + return false + } + next := arm64PoolAffineQuery{previous, state.expression} + if affected := writes & next.expression.registerMask(); affected != 0 { + if savedAt, saved, ok := flow.rewindStackLoad(previous, next.expression); ok { + queue = append(queue, arm64PoolAffineQuery{savedAt, normalize(saved)}) + continue + } + destination, value, valid := flow.affineDefinition(word) + if !valid { + destination, value, valid = flow.maskedResidueDefinition(previous, word, mask) + } + if !valid || affected != 1<>4)) + } + source.WriteString("RET\n") + requireARM64GoAssemblerResult(t, source.String(), true) + file, err := Parse(ArchARM64, source.String()) + if err != nil { + t.Fatal(err) + } + ir, err := Translate(file, Options{Goarch: "arm64", TargetTriple: triple, + Sigs: map[string]FuncSig{"pool_sve_contiguous": { + Name: "pool_sve_contiguous", Args: []LLVMType{Ptr}, Ret: Void, + Frame: FrameLayout{Params: []FrameSlot{{Offset: 0, Type: Ptr, Index: 0, Field: -1}}}, + }}, + }) + if err != nil { + t.Fatal(err) + } + checks := fmt.Sprintf(` + const struct { unsigned memory, element, negative, immediate, active; } cases[] = { +%s + }; + uint8_t out[%d * 256 + 2]; + memset(out, 0xaa, sizeof(out)); + pool_sve_contiguous(out + 1); + if (out[0] != 0xaa || out[sizeof(out)-1] != 0xaa) return 1; + for (unsigned test = 0; test < sizeof(cases)/sizeof(cases[0]); test++) { + unsigned memory = cases[test].memory, element = cases[test].element; + unsigned offset = cases[test].immediate ? 2048 - 2*vl*memory/element : 2048 - 3*memory; + for (unsigned byte = 0; byte < 256; byte++) { + unsigned expected = 0xaa; + if (byte < vl) { + uint64_t value = 0; + if (cases[test].active) { + for (unsigned part = 0; part < memory; part++) { + unsigned at = offset + (byte/element)*memory + part; + uint32_t word = (at/4)*UINT32_C(0x9e3779b9) ^ (at/4 >> 4); + value |= (uint64_t)((word >> ((at%%4)*8)) & 255) << (part*8); + } + if (cases[test].negative && (value & (UINT64_C(1) << (memory*8-1)))) + value |= UINT64_MAX << (memory*8); + } + expected = (value >> ((byte%%element)*8)) & 255; + } + if (out[test*256+byte+1] != expected) { + fprintf(stderr, "SVE contiguous pool case=%%u VL=%%u byte=%%u\n", test, vl, byte); + return 2; + } + } + } +`, cases.String(), count) + const lengths = "16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256" + return ir, arm64SVEVectorLengthsMain("extern void pool_sve_contiguous(uint8_t *);\n", checks, lengths) +} + +func TestARM64RawPoolSVEContiguousLLVM(t *testing.T) { + llc := findLLVM22Tool("llc") + if llc == "" { + t.Fatal("LLVM 22 llc not found") + } + for _, triple := range []string{"aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-pc-windows-msvc"} { + t.Run(triple, func(t *testing.T) { + ir, _ := arm64RawPoolSVEContiguousRuntime(t, triple) + compileLLVMToObject(t, llc, triple, "pool_sve_contiguous.ll", "pool_sve_contiguous.o", ir) + }) + } +} diff --git a/arm64_raw_pool_sve_contiguous_test.go b/arm64_raw_pool_sve_contiguous_test.go new file mode 100644 index 000000000..72abe77d6 --- /dev/null +++ b/arm64_raw_pool_sve_contiguous_test.go @@ -0,0 +1,102 @@ +package plan9asm + +import ( + "fmt" + "testing" +) + +func TestARM64RawPoolContiguousScalableLoads(t *testing.T) { + for _, signed := range []bool{false, true} { + for memory := 0; memory < 4; memory++ { + for element := memory; element < 4; element++ { + if signed && element == memory { + continue + } + form := arm64RawSVELoadCase{memorySize: memory, elementSize: element} + width := 256 >> uint(element-memory) + for _, kind := range []string{"immediate", "register"} { + form.kind = kind + load := form.loadAssembly(signed, 31, 7, 9, 10, 0) + for _, short := range []bool{false, true} { + words := width / 4 + if short { + words-- + } + t.Run(fmt.Sprintf("%s/short=%v", load, short), func(t *testing.T) { + body := "mov x10,#0\n" + load + if got := arm64PoolScalableProof(t, body, words); got == short { + t.Fatalf("pool proof=%v, want %v", got, !short) + } + }) + } + } + } + } + } +} + +func TestARM64RawPoolContiguousScalableIndexGuards(t *testing.T) { + for _, test := range []struct { + name, body string + want bool + }{ + {"bounded-doubleword", "and x10,x10,#7\nld1d {z31.d},p7/z,[x9,x10,lsl #3]", true}, + {"byte-address-in-index", "and x10,x10,#7\nld1b {z31.b},p7/z,[x10,x9]", true}, + {"correlated-displacement", "addvl x9,x9,#-1\ncntd x10\nld1d {z31.d},p7/z,[x9,x10,lsl #3]\naddvl x9,x9,#1", true}, + {"unknown-index", "ld1d {z31.d},p7/z,[x9,x10,lsl #3]", false}, + {"out-of-range-index", "mov x10,#9\nld1d {z31.d},p7/z,[x9,x10,lsl #3]", false}, + {"scaled-relocated-index", "mov x10,#0\nld1d {z31.d},p7/z,[x10,x9,lsl #3]", false}, + {"double-relocation", "ld1b {z31.b},p7/z,[x9,x9]", false}, + {"store-is-not-read", "mov x10,#0\nst1d {z31.d},p7,[x9,x10,lsl #3]", false}, + {"first-fault-not-ordinary", "ldff1d {z31.d},p7/z,[x9]", false}, + {"vector-index-unproved", "ld1d {z31.d},p7/z,[x9,z30.d,lsl #3]", false}, + {"masked-off-still-unproved", "pfalse p7.b\nld1d {z31.d},p7/z,[x9,x10,lsl #3]", false}, + {"vector-length-mode-change", "smstart sm\nld1d {z31.d},p7/z,[x9]", false}, + } { + t.Run(test.name, func(t *testing.T) { + if got := arm64PoolScalableProof(t, test.body, 80); got != test.want { + t.Fatalf("indexed pool proof=%v, want %v", got, test.want) + } + }) + } +} + +func TestARM64RawPoolContiguousScalableImmediateGrammar(t *testing.T) { + var lines []string + var widths []int64 + for _, signed := range []bool{false, true} { + for memory := 0; memory < 4; memory++ { + for element := memory; element < 4; element++ { + if signed && element == memory { + continue + } + form := arm64RawSVELoadCase{memorySize: memory, elementSize: element, kind: "immediate"} + for immediate := -8; immediate < 8; immediate++ { + lines = append(lines, form.loadAssembly(signed, 31, 7, 9, 10, immediate)) + widths = append(widths, 1<= 0 && offset+width <= 1024 + if got := bounds.contiguousScalableLoadInBounds(1, word, row); got != want { + t.Fatalf("%s VL=%d: footprint proof=%v, want %v", lines[index], vectorBytes, got, want) + } + } + } +} diff --git a/arm64_raw_pool_sve_flags_test.go b/arm64_raw_pool_sve_flags_test.go index e97a84154..e0b5cf9fc 100644 --- a/arm64_raw_pool_sve_flags_test.go +++ b/arm64_raw_pool_sve_flags_test.go @@ -26,6 +26,17 @@ func arm64PoolSVEFlagPreservingForms() []string { } } for _, width := range "bhsd" { + for _, immediate := range []int{-128, -1, 0, 1, 127} { + lines = append(lines, fmt.Sprintf("dup z9.%c,#%d", width, immediate)) + if width != 'b' { + lines = append(lines, fmt.Sprintf("dup z9.%c,#%d,lsl #8", width, immediate)) + } + } + general := "w1" + if width == 'd' { + general = "x1" + } + lines = append(lines, fmt.Sprintf("dup z9.%c,%s", width, general)) for _, op := range []string{"and", "bic", "eor", "orr"} { lines = append(lines, fmt.Sprintf("%s z9.%c,p7/m,z9.%c,z31.%c", op, width, width, width)) if op != "bic" { @@ -40,6 +51,11 @@ func arm64PoolSVEFlagPreservingForms() []string { lines = append(lines, fmt.Sprintf("saddv d9,p7,z31.%c", width)) } } + for size, width := range "bhsdq" { + for lane := 0; lane < 64>>size; lane++ { + lines = append(lines, fmt.Sprintf("dup z9.%c,z31.%c[%d]", width, width, lane)) + } + } for _, op := range []string{"and", "bic", "eor", "orr"} { lines = append(lines, fmt.Sprintf("%s z9.d,z30.d,z31.d", op)) } @@ -51,7 +67,8 @@ func arm64PoolSVEFlagPreservingForms() []string { for _, op := range []string{"and", "bic", "eor", "nand", "nor", "orn", "orr"} { lines = append(lines, op+" p9.b,p15/z,p9.b,p14.b") } - return append(lines, "sel p9.b,p15,p9.b,p14.b") + lines = append(lines, "sel p9.b,p15,p9.b,p14.b") + return append(lines, arm64RawSVECopyForms()...) } func TestARM64RawPoolSVEFlagProvenance(t *testing.T) { @@ -118,3 +135,30 @@ func TestARM64RawPoolSVEFlagsRespectControlFlow(t *testing.T) { }) } } + +func TestARM64RawPoolSVECopyGeneralEffects(t *testing.T) { + for _, width := range "bhsd" { + for _, source := range []int{0, 9, 30, 31} { + register := fmt.Sprintf("w%d", source) + if width == 'd' { + register = fmt.Sprintf("x%d", source) + } + if source == 31 { + register = "wsp" + if width == 'd' { + register = "sp" + } + } + line := fmt.Sprintf("cpy z9.%c,p7/m,%s", width, register) + word := assembleARM64LLVMWords(t, []string{line}, "+sve")[0] + if writes, known := arm64RawPoolGPWrites(word); !known || writes != 0 { + t.Errorf("%s: GP writes=%#x, known=%v", line, writes, known) + } + for address := 0; address < 32; address++ { + if ignored := arm64RawPoolSVEIgnoresAddress(word, address); ignored != (address != source) { + t.Errorf("%s: ignored address R%d=%v", line, address, ignored) + } + } + } + } +} diff --git a/arm64_raw_pool_sve_memory.go b/arm64_raw_pool_sve_memory.go index 6fe30fb08..53c69058d 100644 --- a/arm64_raw_pool_sve_memory.go +++ b/arm64_raw_pool_sve_memory.go @@ -14,6 +14,50 @@ func (bounds *arm64RawPoolBounds) wholeScalableLoadInBounds(at int, form arm64Ra } expression := arm64PoolRegisterExpression(form.base) expression.constant = uint64(int64(form.immediate) * width) + return bounds.scalableFootprintInBounds(at, expression, width) +} + +// Reuse the complete signed/unsigned scalar-base LD1 grammar, including +// widening arrangements. Gather, PN, first-fault and non-faulting operations +// have different address/state contracts and are not ordinary contiguous reads. +func arm64RawPoolContiguousLoad(word uint32) (arm64RawSVELoadRow, bool) { + row, ok := arm64RawSVEUnsignedLoadRows[word&0xffe0e000] + if !ok { + row, ok = arm64RawSVELoadRows[word&0xffe0e000] + } + if !ok || row.address != arm64SVELoadRegister && row.address != arm64SVELoadImmediate { + return arm64RawSVELoadRow{}, false + } + // In particular, Xm=31 and the high immediate bit are reserved, not + // aliases of zero. The lowerer's decoder is the authority for these fields. + _, valid := decodeARM64RawSVELoadAddress(word, row) + return row, valid +} + +func (bounds *arm64RawPoolBounds) contiguousScalableLoadInBounds(at int, word uint32, row arm64RawSVELoadRow) bool { + base := int(word>>5) & 31 + if bounds == nil || !bounds.symbolic || bounds.values.vectorBytes == 0 || base == 31 { + return false + } + spec := arm64SVEOrdinaryMemorySpecs[row.op] + // Conservatively assume every predicate lane is active. Extending a + // memory element changes the destination width, never the bytes accessed. + width := bounds.values.vectorBytes * int64(spec.memoryBits) / int64(row.elementBits) + expression := arm64PoolRegisterExpression(base) + if row.address == arm64SVELoadRegister { + index := int(word>>16) & 31 + expression.add(arm64PoolRegisterExpression(index), row.scale) + } else { + immediate := int64(word>>16) & 15 + if immediate >= 8 { + immediate -= 16 + } + expression.constant = uint64(immediate * width) + } + return bounds.scalableFootprintInBounds(at, expression, width) +} + +func (bounds *arm64RawPoolBounds) scalableFootprintInBounds(at int, expression arm64PoolAffine, width int64) bool { value := bounds.values.invariantInterval(at, expression) if value == arm64PoolUnknownInterval { value = bounds.values.affineInterval(at, expression) diff --git a/arm64_raw_pool_sve_values.go b/arm64_raw_pool_sve_values.go index 380c117eb..15fa7150c 100644 --- a/arm64_raw_pool_sve_values.go +++ b/arm64_raw_pool_sve_values.go @@ -102,6 +102,9 @@ func arm64RawPoolAllVectorLengths(instructions []Instr, start, end int, size int if _, memory := decodeARM64RawSVELoadStore(word); memory { usesLength = true } + if _, memory := arm64RawPoolContiguousLoad(word); memory { + usesLength = true + } } if !usesLength { return false diff --git a/arm64_raw_pool_sve_values_runtime_test.go b/arm64_raw_pool_sve_values_runtime_test.go index 8bdd05035..e31f8e977 100644 --- a/arm64_raw_pool_sve_values_runtime_test.go +++ b/arm64_raw_pool_sve_values_runtime_test.go @@ -23,9 +23,9 @@ func arm64RawPoolSVEValuesRuntime(t *testing.T, triple string) (string, string) for _, op := range []string{"cnt", "inc", "dec"} { for width, suffix := range "bhwd" { for _, pattern := range []int{0, 8, 9, 13, 14, 29, 30, 31} { - body := []string{"addvl x9,x9,#-8", "mov x10,#0", + body := []string{"mov x15,#7", "cmp x15,#7", "addvl x9,x9,#-8", "mov x10,#0", fmt.Sprintf("%s%c x10,#%d,mul #16", op, suffix, pattern), - "rdvl x11,#8", "add x9,x9,x11"} + "rdvl x11,#8", "addpl x12,x11,#-1", "b.hs #8", "ldr x1,[x9,#16384]", "add x9,x9,x11"} sign := "+" if op == "dec" { sign = "-" @@ -35,6 +35,17 @@ func arm64RawPoolSVEValuesRuntime(t *testing.T, triple string) (string, string) } } } + for _, op := range []string{"inc", "dec"} { + for size := 1; size < 4; size++ { + for _, pattern := range []int{0, 14, 31} { + for _, multiplier := range []int{1, 16} { + add([]string{"dup z10.d,#1", "mov x15,#7", "cmp x15,#7", + fmt.Sprintf("%s%c z10.%c,#%d,mul #%d", op, "bhwd"[size], "bhsd"[size], pattern, multiplier), + "b.hs #8", "ldr x1,[x9,#16384]"}, "8192") + } + } + } + } machine = append(machine, "ret") for _, at := range origins { machine[at] = fmt.Sprintf("adr x9,#%d", (len(machine)-at)*4+8192) diff --git a/arm64_raw_pool_sve_values_test.go b/arm64_raw_pool_sve_values_test.go index 753d781e2..50c596529 100644 --- a/arm64_raw_pool_sve_values_test.go +++ b/arm64_raw_pool_sve_values_test.go @@ -33,6 +33,7 @@ func TestARM64RawPoolScalableCountGrammar(t *testing.T) { } words := assembleARM64LLVMWords(t, assembly, "+sve") for index, word := range words { + requireARM64PoolScalableFlags(t, assembly[index], word, 1<<10) op, width := index/(4*32*16), index/(32*16)%4 pattern, multiplier := index/16%32, index%16+1 for vectorBytes := int64(16); vectorBytes <= 256; vectorBytes += 16 { @@ -65,6 +66,7 @@ func TestARM64RawPoolScalableAddressGrammar(t *testing.T) { } } for index, word := range assembleARM64LLVMWords(t, assembly, "+sve") { + requireARM64PoolScalableFlags(t, assembly[index], word, 1<<10) for vectorBytes := int64(16); vectorBytes <= 256; vectorBytes += 16 { unit := vectorBytes if index >= 128 { @@ -83,6 +85,17 @@ func TestARM64RawPoolScalableAddressGrammar(t *testing.T) { } } +func requireARM64PoolScalableFlags(t *testing.T, line string, word, wantWrites uint32) { + t.Helper() + flow := &arm64RawPoolValues{ + words: []uint32{0xf1001c3f, word, 0xd503201f}, before: [][]int{{-1}, {0}, {1}}, + } + compare, writes, after, ok := flow.affineFlagSourceBefore(2) + if !ok || compare != flow.words[0] || writes != wantWrites || after != 1 { + t.Fatalf("%s: flags=(%#x,%#x,%d,%v), want preserved with writes=%#x", line, compare, writes, after, ok, wantWrites) + } +} + func arm64PoolScalableProof(t *testing.T, body string, poolWords int) bool { t.Helper() lines := append([]string{"adr x9,#0"}, strings.Split(body, "\n")...) diff --git a/arm64_raw_sve_vector_count_test.go b/arm64_raw_sve_vector_count_test.go index a4c8e680c..fd27e5d09 100644 --- a/arm64_raw_sve_vector_count_test.go +++ b/arm64_raw_sve_vector_count_test.go @@ -67,6 +67,12 @@ func TestARM64RawSVEVectorCountOperandFields(t *testing.T) { } } for i, word := range assembleARM64LLVMWords(t, lines, "+sve") { + requireARM64PoolScalableFlags(t, lines[i], word, 0) + for address := 0; address < 32; address++ { + if !arm64RawPoolSVEIgnoresAddress(word, address) { + t.Fatalf("%s: vector-only count unexpectedly reads R%d", lines[i], address) + } + } got, ok := decodeARM64RawSVECnt(word) if !ok || got != wants[i] { t.Fatalf("%s: decoded %#08x as %+v, %v; want %+v", lines[i], word, got, ok, wants[i]) diff --git a/arm64_raw_unlabelled_pool.go b/arm64_raw_unlabelled_pool.go index bf66d7595..70500461c 100644 --- a/arm64_raw_unlabelled_pool.go +++ b/arm64_raw_unlabelled_pool.go @@ -324,6 +324,16 @@ func arm64RawAddressOnlyLoadedWithinPool(instructions []Instr, at, end int, retu next(i + 1) continue } + if row, ok := arm64RawPoolContiguousLoad(word); ok && + (int(word>>5)&31 == int(register-arm64asm.X0) || + row.address == arm64SVELoadRegister && int(word>>16)&31 == int(register-arm64asm.X0)) { + if !bounds.contiguousScalableLoadInBounds(i, word, row) { + return false + } + loaded = true + next(i + 1) + continue + } // x/arch does not decode SVE. Consult validated typed grammars for // vector-only effects and explicit unrelated scalar/memory operands. // Unknown effects and any use of this address still fail below. @@ -480,6 +490,9 @@ func arm64RawPoolReadOnlyLoad(op arm64asm.Op) bool { } func arm64RawPoolIndependentSVE(word uint32) bool { + if form, ok := decodeARM64RawSVECnt(word); ok && form.vector { + return true + } if _, ok := decodeARM64RawSVEPTrue(word); ok { return true } @@ -552,6 +565,15 @@ func arm64RawPoolSVEIgnoresAddress(word uint32, address int) bool { if arm64RawPoolIndependentSVE(word) { return true } + if ins, ok := decodeARM64RawSVECopy(word); ok && arm64SVECopyGeneralRegister(ins.Args[0]) { + // A GP/SP copy writes only a vector, but copying this pool address + // into that vector would expose a relocation-dependent numeric value. + return int(word>>5)&31 != address + } + if row, ok := arm64RawPoolContiguousLoad(word); ok { + return int(word>>5)&31 != address && + (row.address != arm64SVELoadRegister || int(word>>16)&31 != address) + } if form, ok := decodeARM64RawSVELoadStore(word); ok { return form.base != address } diff --git a/arm64_sve_dup_indexed_test.go b/arm64_sve_dup_indexed_test.go new file mode 100644 index 000000000..8631ed0be --- /dev/null +++ b/arm64_sve_dup_indexed_test.go @@ -0,0 +1,143 @@ +package plan9asm + +import ( + "fmt" + "strings" + "testing" +) + +// Go's encodeImm2Tsz and LLVM's sve_int_perm_dup_i encode an index within +// 512 bits, independently of the vector length currently selected at runtime. +func TestARM64SVEDupIndexedCompleteGrammar(t *testing.T) { + var lines []string + var forms []arm64RawSVEDupElement + var source strings.Builder + source.WriteString("TEXT dup_indexed_forms(SB),$0-0\n") + for size, width := range "bhsdq" { + for lane := 0; lane < 64>>size; lane++ { + lines = append(lines, fmt.Sprintf("dup z2.%c,z31.%c[%d]", width, width, lane)) + forms = append(forms, arm64RawSVEDupElement{elementBits: 8 << size, lane: lane, source: 31, destination: 2}) + fmt.Fprintf(&source, "ZDUP Z31.%c[%d],Z2.%c\n", "BHSDQ"[size], lane, "BHSDQ"[size]) + } + } + source.WriteString("RET\n") + requireARM64SVEGoAssemblerResult(t, source.String(), true) + for index, word := range assembleARM64LLVMWords(t, lines, "+sve") { + if got, ok := decodeARM64RawSVEDupElement(word); !ok || got != forms[index] { + t.Errorf("%s: decoded %+v/%v, want %+v", lines[index], got, ok, forms[index]) + } + } + file, err := Parse(ArchARM64, source.String()) + if err != nil { + t.Fatal(err) + } + if _, err := Translate(file, Options{Goarch: "arm64", TargetTriple: "aarch64-unknown-linux-gnu", + Sigs: map[string]FuncSig{"dup_indexed_forms": {Name: "dup_indexed_forms", Ret: Void}}}); err != nil { + t.Fatal(err) + } +} + +func TestARM64SVEDupIndexedRejectsOutOfRangeLanes(t *testing.T) { + for size, width := range "BHSDQ" { + for _, lane := range []int{-1, 64 >> size} { + instruction := fmt.Sprintf("ZDUP Z31.%c[%d],Z2.%c", width, lane, width) + t.Run(instruction, func(t *testing.T) { + source := "TEXT bad_dup(SB),$0-0\n" + instruction + "\nRET\n" + requireARM64SVEGoAssemblerResult(t, source, false) + file, err := Parse(ArchARM64, source) + if err != nil { + return + } + _, err = Translate(file, Options{Goarch: "arm64", TargetTriple: "aarch64-unknown-linux-gnu", + Sigs: map[string]FuncSig{"bad_dup": {Name: "bad_dup", Ret: Void}}}) + if err == nil { + t.Fatalf("accepted out-of-range lane: %s", instruction) + } + }) + } + } +} + +func arm64SVEDupIndexedRuntime(t *testing.T, triple string) (string, string) { + t.Helper() + var machine []string + var cases strings.Builder + count := 0 + for size, width := range "bhsdq" { + for lane := 0; lane < 64>>size; lane++ { + for _, destination := range []int{2, 31} { + machine = append(machine, "ldr z31,[x1]", + fmt.Sprintf("dup z%d.%c,z31.%c[%d]", destination, width, width, lane), + fmt.Sprintf("str z%d,[x0]", destination), "add x0,x0,#256") + fmt.Fprintf(&cases, " {%d,%d},\n", 1<= attempts || !errors.Is(err, syscall.ENOTEMPTY) { + return err + } + time.Sleep(delay) + } +} + +func removeDiscoveryCandidateWorkspaceOnce(workDir string) error { // Go normally extracts read-only module directories. Only walk this owned // candidate tree; WalkDir does not follow symlinks into any shared cache. if err := filepath.WalkDir(workDir, func(name string, entry os.DirEntry, err error) error { + if os.IsNotExist(err) { + return nil + } if err != nil { return err } if entry.IsDir() { - return os.Chmod(name, 0700) + if err := os.Chmod(name, 0700); err != nil && !os.IsNotExist(err) { + return err + } } return nil }); err != nil { @@ -2129,7 +2318,7 @@ func extractModuleZip(zipPath, destination, modulePath, version string) error { return nil } -func runDiscoveryGoBuild(ctx context.Context, dir string, env []string, target string, buildTags []string, pattern string) error { +func runDiscoveryGoBuild(ctx context.Context, dir string, env []string, target string, buildTags []string, patterns ...string) error { goos, goarch, ok := strings.Cut(target, "/") if !ok || goos == "" || goarch == "" { return fmt.Errorf("invalid discovery target %q", target) @@ -2138,7 +2327,7 @@ func runDiscoveryGoBuild(ctx context.Context, dir string, env []string, target s if len(buildTags) != 0 { args = append(args, "-tags="+strings.Join(buildTags, ",")) } - args = append(args, pattern) + args = append(args, patterns...) targetEnv := replaceEnv(env, map[string]string{ "CGO_ENABLED": "0", "GOOS": goos, @@ -2149,6 +2338,95 @@ func runDiscoveryGoBuild(ctx context.Context, dir string, env []string, target s }) } +// Check all selected packages in one Go invocation when possible. This lets +// Go share package loading and parallel compilation across sibling assembly +// packages. On a source failure, repeat each package independently so the +// corpus can record exact source-inapplicability evidence. Infrastructure +// failures remain fatal, including failures from the initial batch. +func runDiscoveryPackageChecks( + groups []discoveryPackageGroup, + check func([]string) error, +) ([]error, error) { + results := make([]error, len(groups)) + if len(groups) == 0 { + return results, nil + } + if len(groups) > 1 { + patterns := make([]string, 0, len(groups)) + for _, group := range groups { + patterns = append(patterns, group.Pattern) + } + if err := check(patterns); err == nil { + return results, nil + } else if isDiscoveryInfrastructureFailure(err) { + return nil, err + } + } + for index, group := range groups { + results[index] = check([]string{group.Pattern}) + if isDiscoveryInfrastructureFailure(results[index]) { + return nil, fmt.Errorf("package %s: %w", group.Pattern, results[index]) + } + } + return results, nil +} + +// Run independent package operations with a fixed worker count. The first +// failure cancels in-flight commands, but every worker exits before returning +// so no child process can outlive the candidate workspace that owns it. +func runDiscoveryBounded( + parent context.Context, + count, limit int, + run func(context.Context, int) error, +) error { + if limit <= 0 { + return fmt.Errorf("discovery parallelism must be positive") + } + if count == 0 { + return parent.Err() + } + if limit > count { + limit = count + } + ctx, cancel := context.WithCancel(parent) + defer cancel() + jobs := make(chan int) + var workers sync.WaitGroup + var firstFailure sync.Once + var firstErr error + for worker := 0; worker < limit; worker++ { + workers.Add(1) + go func() { + defer workers.Done() + for index := range jobs { + if ctx.Err() != nil { + continue + } + if err := run(ctx, index); err != nil { + firstFailure.Do(func() { + firstErr = err + cancel() + }) + } + } + }() + } +dispatch: + for index := 0; index < count; index++ { + select { + case <-ctx.Done(): + break dispatch + case jobs <- index: + } + } + close(jobs) + workers.Wait() + if firstErr != nil { + return firstErr + } + return parent.Err() +} + // Downloading a module and building its dependencies can both encounter // transient proxy failures. Retry only network diagnostics within the original // operation deadline. Source, checksum, resource and toolchain errors need a @@ -2159,7 +2437,7 @@ func retryDiscoveryGoNetwork(ctx context.Context, delays []time.Duration, operat return err } err := operation() - if err == nil || attempt >= len(delays) || !isDiscoveryRetryableNetworkFailure(err.Error()) { + if err == nil || attempt >= len(delays) || !isDiscoveryRetryableNetworkFailure(discoveryCommandDiagnostic(err)) { return err } select { @@ -2170,72 +2448,11 @@ func retryDiscoveryGoNetwork(ctx context.Context, delays []time.Duration, operat } } -func isDiscoveryRetryableNetworkFailure(diagnostic string) bool { - if !isDiscoveryGoBuildInfrastructureFailure(diagnostic) { - return false - } - diagnostic = strings.ToLower(diagnostic) - for _, marker := range []string{ - "too many requests", - "service unavailable", - "bad gateway", - "gateway timeout", - "i/o timeout", - "tls handshake timeout", - "connection reset", - "connection closed by", - "temporary failure", - "unexpected eof", - } { - if strings.Contains(diagnostic, marker) { - return true - } - } - return false -} - -func isDiscoveryGoBuildInfrastructureFailure(diagnostic string) bool { - diagnostic = strings.ToLower(diagnostic) - // "unexpected EOF" is also the Go assembler's deterministic diagnostic - // for a truncated source file. Do not retain that source failure as a - // transient network retry merely because proxies use the same phrase. - if strings.Contains(diagnostic, "unexpected eof") && - strings.Contains(diagnostic, ".s:") && - strings.Contains(diagnostic, "asm: assembly of") { - return false - } - for _, marker := range []string{ - "captured output exceeds", - "does not match go tool version", - "context deadline exceeded", - "i/o timeout", - "tls handshake timeout", - "temporary failure", - "no such host", - "connection refused", - "connection reset", - "connection closed by", - "could not read from remote repository", - "network is unreachable", - "proxyconnect", - "unexpected eof", - "bad gateway", - "service unavailable", - "too many requests", - "gateway timeout", - "no space left on device", - "signal: killed", - } { - if strings.Contains(diagnostic, marker) { - return true - } - } - return false -} - func isDiscoveryInfrastructureFailure(err error) bool { return err != nil && (errors.Is(err, errDiscoveryCommandOutputExceeded) || - isDiscoveryGoBuildInfrastructureFailure(err.Error())) + errors.Is(err, exec.ErrWaitDelay) || + errors.Is(err, errDiscoveryCommandCleanup) || + isDiscoveryGoBuildInfrastructureFailure(discoveryCommandDiagnostic(err))) } func runDiscoveryAsmDecl(ctx context.Context, dir string, env []string, target string, buildTags, patterns []string) error { @@ -2257,19 +2474,21 @@ func runDiscoveryAsmDecl(ctx context.Context, dir string, env []string, target s if err == nil { return nil } - if isDiscoveryInfrastructureFailure(err) { + // A test-only dependency may fail at the proxy before vet inspects valid + // production assembly. Retry without tests for transient network errors; + // resource, integrity, and toolchain failures still stop immediately. + if isDiscoveryInfrastructureFailure(err) && + !isDiscoveryRetryableNetworkFailure(discoveryCommandDiagnostic(err)) { return err } if ctx.Err() != nil { return err } - if isDiscoveryAsmDeclABIMismatch(err.Error()) { - return err - } // go vet type-checks package tests before running asmdecl. Old modules can - // have tests that no longer compile even though their production package - // builds; retry against temporary module copies with only *_test.go files - // emptied so unrelated diagnostics cannot hide an assembly ABI mismatch. + // have tests that no longer compile or import another package with an ABI + // mismatch even though their production package builds. Retry against + // temporary module copies with only *_test.go files emptied so unrelated + // diagnostics cannot hide this package's assembly ABI result. // Go forbids overlays beneath GOMODCACHE, hence the explicit module copies. retryErr := runDiscoveryAsmDeclWithTestlessModuleCopies(ctx, dir, targetEnv, args, buildTags, patterns) if ctxErr := ctx.Err(); ctxErr != nil { @@ -2278,12 +2497,16 @@ func runDiscoveryAsmDecl(ctx context.Context, dir string, env []string, target s } return fmt.Errorf("go vet -asmdecl: %w", ctxErr) } - if retryErr != nil && isDiscoveryAsmDeclABIMismatch(retryErr.Error()) { + if retryErr != nil && isDiscoveryAsmDeclABIMismatch(discoveryCommandDiagnostic(retryErr)) { return retryErr } if isDiscoveryInfrastructureFailure(retryErr) { return retryErr } + if retryErr != nil && + (isDiscoveryInfrastructureFailure(err) || isDiscoveryAsmDeclABIMismatch(discoveryCommandDiagnostic(err))) { + return fmt.Errorf("testless go vet did not clear the original failure: %v; original: %w", retryErr, err) + } return nil } @@ -2385,7 +2608,7 @@ func runDiscoveryAsmDeclWithTestlessModuleCopies(ctx context.Context, dir string } } if !hasTests { - return nil + return errors.New("testless asmdecl retry found no selected test files") } formattedGoMod, err := parsedGoMod.Format() if err != nil { @@ -2438,20 +2661,161 @@ func isDiscoveryAsmDeclABIMismatch(diagnostic string) bool { func discoveryAsmDeclRejectedFiles(asmFiles []string, diagnostic string) []string { diagnostic = filepath.ToSlash(diagnostic) - var rejected []string - for _, asmFile := range asmFiles { - asmFile = filepath.ToSlash(asmFile) - if strings.Contains(diagnostic, asmFile+":") || strings.Contains(diagnostic, "/"+asmFile+":") { - rejected = append(rejected, asmFile) + rejected := make(map[string]bool) + for _, line := range strings.Split(diagnostic, "\n") { + if !isDiscoveryAsmDeclABIMismatch(line) { + continue + } + for _, asmFile := range asmFiles { + if discoveryAsmDeclLineNamesFile(line, asmFile) { + rejected[asmFile] = true + } + } + } + return sortedDiscoverySet(rejected) +} + +// go vet -asmdecl reports every file selected in a package, including a +// default-tag file when this corpus configuration was seeded by a tagged file. +// Expand only the diagnostic attribution set. Translation still uses the +// minimal file/target configurations selected by discovery. +func discoveryAsmDeclPackageFiles( + candidate discoveryCandidate, + group discoveryPackageGroup, + moduleDir, target string, + buildTags []string, +) ([]string, error) { + if len(group.AsmFiles) == 0 { + return nil, fmt.Errorf("empty assembly package group") + } + goos, goarch, ok := strings.Cut(target, "/") + if !ok || goos == "" || goarch == "" { + return nil, fmt.Errorf("invalid discovery target %q", target) + } + ctx := build.Default + ctx.GOOS = goos + ctx.GOARCH = goarch + ctx.Compiler = "gc" + ctx.CgoEnabled = false + ctx.BuildTags = buildTags + + dirRel := path.Dir(group.AsmFiles[0]) + dir := filepath.Join(moduleDir, filepath.FromSlash(dirRel)) + for _, asmFile := range group.AsmFiles[1:] { + if path.Dir(asmFile) != dirRel { + return nil, fmt.Errorf("assembly package group spans multiple directories") + } + } + selected := make(map[string]bool, len(candidate.AsmFiles)) + for _, asmFile := range candidate.AsmFiles { + if path.Dir(asmFile) != dirRel { + continue + } + matches, err := ctx.MatchFile(dir, path.Base(asmFile)) + if err != nil { + return nil, fmt.Errorf("match assembly %s for %s with tags %v: %w", + asmFile, target, buildTags, err) + } + if matches { + selected[asmFile] = true + } + } + for _, asmFile := range group.AsmFiles { + if !selected[asmFile] { + return nil, fmt.Errorf("selected package source %s does not match Go build constraints", asmFile) + } + } + return sortedDiscoverySet(selected), nil +} + +func classifyDiscoveryAsmDeclFailure(asmFiles []string, err error) ([]string, error) { + if err == nil { + return nil, nil + } + if isDiscoveryInfrastructureFailure(err) { + return nil, err + } + diagnostic := discoveryCommandDiagnostic(err) + if !isDiscoveryAsmDeclABIMismatch(diagnostic) { + return nil, fmt.Errorf("go vet failed without a concrete ABI mismatch: %w", err) + } + rejected := discoveryAsmDeclRejectedFiles(asmFiles, diagnostic) + if len(rejected) == 0 { + return nil, fmt.Errorf("go vet ABI mismatch does not identify an applicable assembly file: %w", err) + } + return rejected, nil +} + +// A package's vet action can report an ABI mismatch in another package from +// the same discovered module. That other file has its own package/target +// verification; do not assign its mismatch to the package under inspection. +// Unknown files and infrastructure failures still stop the candidate. +func discoveryAsmDeclOnlyForeignCandidateABI( + candidateFiles, packageFiles []string, + err error, +) bool { + if err == nil || isDiscoveryInfrastructureFailure(err) { + return false + } + foreign := false + for _, line := range strings.Split(filepath.ToSlash(discoveryCommandDiagnostic(err)), "\n") { + if !isDiscoveryAsmDeclABIMismatch(line) { + continue + } + for _, file := range packageFiles { + if discoveryAsmDeclLineNamesFile(line, file) { + return false + } + } + matched := false + for _, file := range candidateFiles { + if discoveryAsmDeclLineNamesFile(line, file) { + matched = true + break + } + } + if !matched { + return false + } + foreign = true + } + return foreign +} + +func discoveryAsmDeclLineNamesFile(line, asmFile string) bool { + asmFile = filepath.ToSlash(asmFile) + end := strings.LastIndex(line, ".s:") + if end < 0 { + return false + } + reported := strings.TrimSpace(line[:end+2]) + if strings.Contains(reported, "/") { + return reported == asmFile || strings.HasSuffix(reported, "/"+asmFile) + } + return reported == path.Base(asmFile) +} + +func discoveryAsmDeclRejectionEvidence(err error, rejected []string) string { + if err == nil { + return "" + } + var proof []string + seen := make(map[string]bool, len(rejected)) + for _, line := range strings.Split(filepath.ToSlash(discoveryCommandDiagnostic(err)), "\n") { + if !isDiscoveryAsmDeclABIMismatch(line) { continue } - // The Go command sometimes shortens diagnostics to a package-local - // basename. Only use that fallback when it identifies this file. - if strings.Contains(diagnostic, path.Base(asmFile)+":") { - rejected = append(rejected, asmFile) + for _, asmFile := range rejected { + if !seen[asmFile] && discoveryAsmDeclLineNamesFile(line, asmFile) { + proof = append(proof, line) + seen[asmFile] = true + } } } - return uniqueSortedDiscoveryStrings(rejected) + if len(proof) == 0 { + return limitDiscoveryEvidence(err.Error(), 8192) + } + return limitDiscoveryEvidence(strings.Join(proof, "\n"), 8192) } func subtractDiscoveryStrings(values, removed []string) []string { @@ -2505,6 +2869,40 @@ const discoveryCommandOutputLimit = 8 << 20 var errDiscoveryCommandOutputExceeded = errors.New("captured output exceeds limit") +var errDiscoveryCommandCleanup = errors.New("terminate discovery command group") + +// Keep the bounded complete diagnostic for classification while keeping error +// messages and persisted report reasons compact. Trimming display text must +// never change which assembly files are excluded by an ABI error. +type discoveryCapturedCommandError struct { + command string + cause error + output string + display string +} + +func (failure *discoveryCapturedCommandError) Error() string { + if failure.display == "" { + return fmt.Sprintf("%s: %v", failure.command, failure.cause) + } + return fmt.Sprintf("%s: %v\n%s", failure.command, failure.cause, failure.display) +} + +func (failure *discoveryCapturedCommandError) Unwrap() error { + return failure.cause +} + +func discoveryCommandDiagnostic(err error) string { + if err == nil { + return "" + } + var failure *discoveryCapturedCommandError + if errors.As(err, &failure) { + return err.Error() + "\n" + failure.output + } + return err.Error() +} + // Candidate tools can emit arbitrarily large diagnostics. Bound the capture // while still draining both pipes so the child cannot block on a full pipe. // A truncated command is an explicit failure, never a successful inspection. @@ -2545,7 +2943,10 @@ func runCapturedCommandOutput(ctx context.Context, dir string, env []string, nam var output boundedDiscoveryCommandOutput cmd.Stdout = &output cmd.Stderr = &output - err := cmd.Run() + // Do not wait indefinitely when an escaped descendant retains an output + // pipe. ErrWaitDelay is infrastructure failure, never source N/A. + cmd.WaitDelay = 2 * time.Second + err := runDiscoveryCommand(cmd) if ctx.Err() != nil { return nil, fmt.Errorf("%s: %w", strings.Join(append([]string{name}, args...), " "), ctx.Err()) } @@ -2565,10 +2966,13 @@ func runCapturedCommandOutput(ctx context.Context, dir string, env []string, nam if err == nil { return output.Bytes(), nil } - if message == "" { - return nil, fmt.Errorf("%s: %w", strings.Join(append([]string{name}, args...), " "), err) + failure := &discoveryCapturedCommandError{ + command: strings.Join(append([]string{name}, args...), " "), + cause: err, + output: output.String(), + display: message, } - return output.Bytes(), fmt.Errorf("%s: %w\n%s", strings.Join(append([]string{name}, args...), " "), err, message) + return output.Bytes(), failure } func replaceEnv(base []string, replacements map[string]string) []string { diff --git a/cmd/plan9asmcorpus/discovery_cleanup_test.go b/cmd/plan9asmcorpus/discovery_cleanup_test.go new file mode 100644 index 000000000..27457636e --- /dev/null +++ b/cmd/plan9asmcorpus/discovery_cleanup_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" +) + +func TestDiscoveryGitMaintenanceCannotDetach(t *testing.T) { + for _, key := range []string{"gc.autoDetach", "maintenance.autoDetach"} { + command := exec.Command("git", "config", "--bool", "--get", key) + command.Dir = t.TempDir() + command.Env = discoveryCommandEnvironment(os.Environ()) + output, err := command.CombinedOutput() + if err != nil || strings.TrimSpace(string(output)) != "false" { + t.Errorf("%s = %q, %v; want foreground maintenance", key, output, err) + } + } +} + +func TestDiscoveryCleanupRetriesOnlyTransientDirectoryRaces(t *testing.T) { + for _, test := range []struct { + name string + cause error + failures int + wantRuns int + wantErr bool + }{ + {"directory populated during unlink", syscall.ENOTEMPTY, 1, 2, false}, + {"persistent writer", syscall.ENOTEMPTY, 100, 3, true}, + {"permission denied", os.ErrPermission, 1, 1, true}, + {"other filesystem error", syscall.EIO, 1, 1, true}, + } { + t.Run(test.name, func(t *testing.T) { + calls := 0 + err := retryDiscoveryWorkspaceCleanup(3, 0, func() error { + calls++ + if calls <= test.failures { + return &os.PathError{Op: "unlinkat", Path: "candidate/module-cache/cache/vcs", Err: test.cause} + } + return nil + }) + if calls != test.wantRuns || (err != nil) != test.wantErr { + t.Fatalf("cleanup calls=%d err=%v, want calls=%d error=%v", calls, err, test.wantRuns, test.wantErr) + } + if test.wantErr && !errors.Is(err, test.cause) { + t.Fatalf("lost cleanup failure: %v", err) + } + }) + } +} + +func TestDiscoveryCleanupAlreadyRemoved(t *testing.T) { + root := t.TempDir() + work := filepath.Join(root, "already-removed") + if err := removeDiscoveryCandidateWorkspace(work); err != nil { + t.Fatalf("already removed workspace: %v", err) + } + // Existing read-only directory coverage is in discovery_test.go. Keep + // idempotency explicit because retries can encounter disappearing entries. +} diff --git a/cmd/plan9asmcorpus/discovery_diagnostic.go b/cmd/plan9asmcorpus/discovery_diagnostic.go new file mode 100644 index 000000000..43e262c43 --- /dev/null +++ b/cmd/plan9asmcorpus/discovery_diagnostic.go @@ -0,0 +1,152 @@ +package main + +import ( + "regexp" + "strconv" + "strings" +) + +// net/url.Error quotes the request URL. Require that HTTP request envelope +// instead of retrying any source/file diagnostic that happens to end in EOF. +var discoveryHTTPResponseEOFPattern = regexp.MustCompile( + `(?m)(?:^|[ :\t])(?:get|head|post|put|delete|patch|options|connect|trace)` + + ` "https?://[^"\r\n]+": eof[ \t]*\r?$`, +) + +// Go's HTTP/2 transport can reset a module ZIP or checksum tile stream after +// the request starts. Only retry this diagnostic when it names an HTTP read; +// a source or tool error containing "stream error" is not a network failure. +var discoveryHTTP2StreamErrorPattern = regexp.MustCompile( + `(?m)(?:^|[ :\t])(?:read|reading) "?https?://[^"\r\n \t]+"?: ` + + `stream error: stream id [0-9]+; [a-z_]+; received from peer`, +) + +// A proxy shutting down can close an in-flight HTTP/2 module or checksum +// request with GOAWAY/NO_ERROR. Require the HTTP read URL and shutdown reason; +// an arbitrary source or tool diagnostic mentioning GOAWAY is not retryable. +var discoveryHTTP2GoAwayPattern = regexp.MustCompile( + `(?m)(?:^|[ :\t])(?:read|reading) "?https?://[^"\r\n \t]+"?: ` + + `http2: server sent goaway and closed the connection; ` + + `laststreamid=[0-9]+, errcode=no_error, debug="server_shutting_down"`, +) + +func isDiscoveryRetryableNetworkFailure(diagnostic string) bool { + _, retryable := classifyDiscoveryGoBuildFailure(diagnostic) + return retryable +} + +func isDiscoveryGoBuildInfrastructureFailure(diagnostic string) bool { + infrastructure, _ := classifyDiscoveryGoBuildFailure(diagnostic) + return infrastructure +} + +func classifyDiscoveryGoBuildFailure(diagnostic string) (infrastructure, retryable bool) { + diagnostic = discoveryNonSourceDiagnostics(diagnostic) + // These failures need a different fix even if another concurrent package + // also encounters a transient network error. Never retry past an integrity + // failure or hide a resource/toolchain failure as source incompatibility. + for _, marker := range []string{ + "checksum mismatch", + "security error", + "captured output exceeds", + "waitdelay expired", + "terminate discovery command group", + "does not match go tool version", + "context deadline exceeded", + "context canceled", + "executable file not found", + "exec format error", + "permission denied", + "resource temporarily unavailable", + "out of memory", + "no space left on device", + "signal: killed", + } { + if strings.Contains(diagnostic, marker) { + return true, false + } + } + if discoveryHTTPResponseEOFPattern.MatchString(diagnostic) || + discoveryHTTP2StreamErrorPattern.MatchString(diagnostic) || + discoveryHTTP2GoAwayPattern.MatchString(diagnostic) { + return true, true + } + for _, marker := range []string{ + "too many requests", + "service unavailable", + "bad gateway", + "gateway timeout", + "i/o timeout", + "tls handshake timeout", + "connection reset", + "connection closed by", + "temporary failure", + "unexpected eof", + } { + if strings.Contains(diagnostic, marker) { + return true, true + } + } + for _, marker := range []string{ + "no such host", + "connection refused", + "could not read from remote repository", + "network is unreachable", + "proxyconnect", + } { + if strings.Contains(diagnostic, marker) { + return true, false + } + } + return false, false +} + +// A truncated assembly source and a truncated HTTP response both report +// "unexpected EOF". Exclude only the exact source diagnostic, never the +// whole command output: another package may have failed for an independent +// network, resource or integrity reason in that same build. +func discoveryNonSourceDiagnostics(diagnostic string) string { + normalized := strings.ToLower(diagnostic) + if !strings.Contains(normalized, "unexpected eof") { + return normalized + } + + lines := strings.Split(diagnostic, "\n") + failedSources := make(map[string]bool) + for _, line := range lines { + line = strings.TrimSuffix(line, "\r") + const prefix, suffix = "asm: assembly of ", " failed" + if strings.HasPrefix(line, prefix) && strings.HasSuffix(line, suffix) { + filename := strings.TrimSuffix(strings.TrimPrefix(line, prefix), suffix) + failedSources[filename] = true + } + } + for index, line := range lines { + if discoveryAssemblerEOFLine(strings.TrimSuffix(line, "\r"), failedSources) { + lines[index] = "" + } + } + return strings.ToLower(strings.Join(lines, "\n")) +} + +func discoveryAssemblerEOFLine(line string, failedSources map[string]bool) bool { + const suffix = ": unexpected eof" + if !strings.HasSuffix(strings.ToLower(line), suffix) { + return false + } + location := line[:len(line)-len(suffix)] + separator := strings.LastIndex(location, ".s:") + if separator < 0 || !failedSources[location[:separator+2]] { + return false + } + positions := strings.Split(location[separator+3:], ":") + if len(positions) > 2 { + return false + } + for _, text := range positions { + if _, err := strconv.ParseUint(text, 10, 32); err != nil { + return false + } + } + return true +} diff --git a/cmd/plan9asmcorpus/discovery_diagnostic_test.go b/cmd/plan9asmcorpus/discovery_diagnostic_test.go new file mode 100644 index 000000000..8d362324a --- /dev/null +++ b/cmd/plan9asmcorpus/discovery_diagnostic_test.go @@ -0,0 +1,292 @@ +package main + +import ( + "context" + "errors" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +const discoveryTestHTTP2GoAway = `http2: server sent GOAWAY and closed the connection; ` + + `LastStreamID=3, ErrCode=NO_ERROR, debug="server_shutting_down"` + +func TestDiscoveryMixedAssemblerAndInfrastructureDiagnostics(t *testing.T) { + const source = "pkg/file.s:12: unexpected EOF\nasm: assembly of pkg/file.s failed" + for _, diagnostic := range []struct { + name string + message string + retryable bool + }{ + {"proxy", "reading https://example.com/pkg: 503 Service Unavailable", true}, + {"network EOF", "Get https://example.com/pkg: unexpected EOF", true}, + {"checksum HTTP2 stream", "reading https://sum.golang.org/tile/8/0/x218/247: stream error: stream ID 13; INTERNAL_ERROR; received from peer", true}, + {"module ZIP HTTP2 stream", "read \"https://proxy.golang.org/example.com/pkg/@v/v1.0.0.zip\": stream error: stream ID 5; INTERNAL_ERROR; received from peer", true}, + { + "module HTTP2 GOAWAY", + `read "https://proxy.golang.org/example.com/pkg/@v/v1.0.0.mod": ` + discoveryTestHTTP2GoAway, + true, + }, + {"assembly URL EOF", "Get https://example.com/file.s:12: unexpected EOF", true}, + {"disk", "write object.o: no space left on device", false}, + {"killed", "go build: signal: killed", false}, + {"output pipe deadline", "exec: WaitDelay expired before I/O complete", false}, + {"descendant cleanup", "terminate discovery command group: operation not permitted", false}, + {"toolchain", "compile: version does not match go tool version", false}, + {"checksum", "verifying example.com/pkg: checksum mismatch\nSECURITY ERROR", false}, + } { + t.Run(diagnostic.name, func(t *testing.T) { + for _, combined := range []string{ + source + "\n" + diagnostic.message, + diagnostic.message + "\n" + source, + } { + if !isDiscoveryGoBuildInfrastructureFailure(combined) { + t.Fatalf("mixed failure was classified as source N/A: %s", combined) + } + if got := isDiscoveryRetryableNetworkFailure(combined); got != diagnostic.retryable { + t.Fatalf("retryable=%v, want %v: %s", got, diagnostic.retryable, combined) + } + } + }) + } +} + +func TestDiscoveryAssemblerEOFRequiresMatchingSourceDiagnostic(t *testing.T) { + for _, filename := range []string{"pkg/file.s", "pkg with spaces/file.s", `C:\source\file.s`} { + for _, newline := range []string{"\n", "\r\n"} { + for _, position := range []string{"12", "12:3"} { + diagnostic := filename + ":" + position + ": unexpected EOF" + newline + + "asm: assembly of " + filename + " failed" + newline + if isDiscoveryGoBuildInfrastructureFailure(diagnostic) || isDiscoveryRetryableNetworkFailure(diagnostic) { + t.Fatalf("deterministic assembler EOF must remain source N/A: %q", diagnostic) + } + } + } + } + for _, diagnostic := range []string{ + "pkg/file.s:12: unexpected EOF\nasm: assembly of other.s failed", + "pkg/File.s:12: unexpected EOF\nasm: assembly of pkg/file.s failed", + "pkg/file.s:invalid: unexpected EOF\nasm: assembly of pkg/file.s failed", + "pkg/file.s:12:3:4: unexpected EOF\nasm: assembly of pkg/file.s failed", + "pkg/file.s:12: unexpected EOF while fetching dependency\nasm: assembly of pkg/file.s failed", + "pkg/file.s:12: unexpected EOF", + } { + if !isDiscoveryGoBuildInfrastructureFailure(diagnostic) { + t.Fatalf("ambiguous EOF diagnostic was treated as source rejection: %q", diagnostic) + } + } +} + +func TestDiscoveryHTTP2StreamRetryRequiresHTTPRead(t *testing.T) { + for _, diagnostic := range []string{ + "pkg/file.s:12: stream error: stream ID 13; INTERNAL_ERROR; received from peer", + "go build: stream error: stream ID 13; INTERNAL_ERROR; received from peer", + } { + if isDiscoveryRetryableNetworkFailure(diagnostic) { + t.Fatalf("non-HTTP stream error was retried: %s", diagnostic) + } + } +} + +func TestDiscoveryHTTP2GoAwayRetryRequiresHTTPReadAndShutdown(t *testing.T) { + for _, diagnostic := range []string{ + `read "https://proxy.golang.org/example.com/pkg/@v/v1.0.0.mod": ` + discoveryTestHTTP2GoAway, + `reading https://sum.golang.org/tile/8/0/x218/247: ` + discoveryTestHTTP2GoAway, + } { + if !isDiscoveryGoBuildInfrastructureFailure(diagnostic) || !isDiscoveryRetryableNetworkFailure(diagnostic) { + t.Errorf("HTTP/2 server shutdown must be retried: %s", diagnostic) + } + } + for _, diagnostic := range []string{ + "go build: " + discoveryTestHTTP2GoAway, + "pkg/file.s:12: " + discoveryTestHTTP2GoAway, + `read "file:///source.s": ` + discoveryTestHTTP2GoAway, + `read "https://proxy.golang.org/pkg.mod": ` + + `http2: server sent GOAWAY and closed the connection; ` + + `LastStreamID=3, ErrCode=PROTOCOL_ERROR, debug="protocol_error"`, + } { + if isDiscoveryRetryableNetworkFailure(diagnostic) { + t.Errorf("non-shutdown source/tool diagnostic was retried: %s", diagnostic) + } + } + if isDiscoveryRetryableNetworkFailure("checksum mismatch\n" + + `read "https://proxy.golang.org/pkg.mod": ` + discoveryTestHTTP2GoAway) { + t.Fatal("checksum mismatch must not be hidden by an HTTP/2 shutdown") + } +} + +func TestDiscoveryRetryHTTP2GoAway(t *testing.T) { + const diagnostic = `read "https://proxy.golang.org/example.com/pkg/@v/v1.0.0.mod": ` + + discoveryTestHTTP2GoAway + attempts := 0 + err := retryDiscoveryGoNetwork(context.Background(), []time.Duration{0}, func() error { + attempts++ + if attempts == 1 { + return errors.New(diagnostic) + } + return nil + }) + if err != nil || attempts != 2 { + t.Fatalf("GOAWAY retry: attempts=%d error=%v, want two attempts and success", attempts, err) + } +} + +func TestDiscoveryFatalFailurePrecedesTransientNetworkDiagnostic(t *testing.T) { + for _, fatal := range []string{ + "verifying example.com/pkg: checksum mismatch\nSECURITY ERROR", + "write object.o: no space left on device", + "go build: signal: killed", + "compile: version does not match go tool version", + "go build: captured output exceeds 8388608 bytes", + "go build: context deadline exceeded", + "go build: context canceled", + "exec: go: executable file not found in PATH", + "fork/exec go: exec format error", + "fork/exec go: permission denied", + "fork/exec compile: resource temporarily unavailable", + "fatal error: runtime: out of memory", + } { + if !isDiscoveryGoBuildInfrastructureFailure(fatal) { + t.Errorf("infrastructure error was classified as source N/A: %s", fatal) + } + diagnostic := fatal + "\nreading https://example.com/pkg: 503 Service Unavailable" + if !isDiscoveryGoBuildInfrastructureFailure(diagnostic) { + t.Fatalf("fatal error was classified as source N/A: %s", diagnostic) + } + if isDiscoveryRetryableNetworkFailure(diagnostic) { + t.Errorf("fatal error was retried because another dependency had a network failure: %s", diagnostic) + } + } +} + +func TestDiscoveryChecksumFailureCannotBecomeSourceNotApplicable(t *testing.T) { + diagnostic := "verifying example.com/pkg: checksum mismatch\nSECURITY ERROR" + if !isDiscoveryGoBuildInfrastructureFailure(diagnostic) { + t.Fatal("checksum mismatch was classified as source incompatibility") + } + if isDiscoveryRetryableNetworkFailure(diagnostic) { + t.Fatal("checksum mismatch must not be retried as a transient network error") + } +} + +func TestDiscoveryRetryMixedAssemblerNetworkEOF(t *testing.T) { + const source = "pkg/file.s:12: unexpected EOF\nasm: assembly of pkg/file.s failed" + attempts := 0 + err := retryDiscoveryGoNetwork(context.Background(), []time.Duration{0}, func() error { + attempts++ + if attempts == 1 { + return errors.New(source + "\nGet https://example.com/pkg: unexpected EOF") + } + return errors.New(source) + }) + if attempts != 2 || err == nil || !strings.Contains(err.Error(), source) { + t.Fatalf("attempts=%d error=%v, want retry followed by source rejection", attempts, err) + } +} + +func TestDiscoveryActualAssemblerEOFRemainsSourceRejection(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, filepath.Join(dir, "go.mod"), "module example.com/truncatedasm\n\ngo 1.20\n") + writeTestFile(t, filepath.Join(dir, "decl.go"), "package truncatedasm\n\nfunc f()\n") + // Deliberately omit the final newline to exercise the real Go diagnostic. + writeTestFile(t, filepath.Join(dir, "decl_amd64.s"), "TEXT ·f(SB), $0-0\nRET") + env := replaceEnv(os.Environ(), map[string]string{"GOFLAGS": "-mod=mod", "GOWORK": "off"}) + err := runDiscoveryGoBuild(context.Background(), dir, env, "linux/amd64", nil, "example.com/truncatedasm") + if err == nil || !strings.Contains(err.Error(), "unexpected EOF") { + t.Fatalf("expected an actual assembler EOF, got %v", err) + } + if isDiscoveryInfrastructureFailure(err) || isDiscoveryRetryableNetworkFailure(err.Error()) { + t.Fatalf("actual Go source rejection was classified as infrastructure failure: %v", err) + } +} + +func TestDiscoveryProgressRejectsInfrastructureSourceNotApplicable(t *testing.T) { + for _, status := range []string{discoveryStatusPassed, discoveryStatusNotApplicable} { + t.Run(status, func(t *testing.T) { + ledger, reports, source := writeDiscoveryReportFixture(t) + files, err := discoveryCorpusReportFiles(reports) + if err != nil { + t.Fatal(err) + } + for _, filename := range files { + report, err := readDiscoveryCorpusReport(filename) + if err != nil { + t.Fatal(err) + } + if len(report.Results) == 0 { + continue + } + result := &report.Results[0] + result.SourceNotApplicableItems = []discoverySourceNotApplicableItem{{ + AsmFiles: result.DiscoveredAsmFiles, + Targets: []string{"linux/arm64"}, + Kind: discoverySourceNotApplicableGoBuild, + Reason: "pkg/file.s:12: unexpected EOF\nasm: assembly of pkg/file.s failed\n" + + "reading https://example.com/pkg: 503 Service Unavailable", + }} + if status == discoveryStatusNotApplicable { + result.Status = status + result.NotApplicableReason = "current Go package rejected" + report.Passed-- + report.NotApplicable++ + report.Translations -= result.Translations + result.Translations = 0 + } + if err := writeDiscoveryCorpusReport(filename, report); err != nil { + t.Fatal(err) + } + targets := []string{"linux/amd64", "linux/arm64"} + if _, err := collectDiscoveryProgress(ledger, reports, targets, source, 2); err == nil || + !strings.Contains(err.Error(), "infrastructure failure") { + t.Fatalf("progress must reject infrastructure N/A before ledger publication: %v", err) + } + if err := verifyDiscoveryCorpusReports(ledger, reports, targets, source); err == nil || + !strings.Contains(err.Error(), "infrastructure failure") { + t.Fatalf("final gate accepted infrastructure N/A: %v", err) + } + return + } + t.Fatal("fixture contains no candidate") + }) + } +} + +func TestDiscoveryHTTPResponseEOFIsRetryable(t *testing.T) { + for _, method := range []string{ + http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, + http.MethodDelete, http.MethodPatch, http.MethodOptions, + http.MethodConnect, http.MethodTrace, + } { + for _, scheme := range []string{"http", "https"} { + failure := &url.Error{Op: method, URL: scheme + "://example.com/sumdb/supported", Err: io.EOF} + for _, diagnostic := range []string{ + failure.Error(), + "verifying go.mod: initializing sumdb.Client: checking tree#1: " + failure.Error(), + failure.Error() + "\r\n", + } { + if !isDiscoveryGoBuildInfrastructureFailure(diagnostic) || !isDiscoveryRetryableNetworkFailure(diagnostic) { + t.Errorf("HTTP response EOF must be an infrastructure retry: %s", diagnostic) + } + } + terminal := "checksum mismatch\n" + failure.Error() + if isDiscoveryRetryableNetworkFailure(terminal) { + t.Errorf("HTTP EOF must not override checksum failure: %s", terminal) + } + } + } + for _, diagnostic := range []string{ + "source.s:12: EOF", + "EOF", + `Get "file:///source.s": EOF`, + `Get "https://example.com/module": EOF in source`, + } { + if isDiscoveryRetryableNetworkFailure(diagnostic) { + t.Errorf("non-HTTP EOF was retried as a network failure: %s", diagnostic) + } + } +} diff --git a/cmd/plan9asmcorpus/discovery_download_retry_test.go b/cmd/plan9asmcorpus/discovery_download_retry_test.go index 208f0e93a..be321e49b 100644 --- a/cmd/plan9asmcorpus/discovery_download_retry_test.go +++ b/cmd/plan9asmcorpus/discovery_download_retry_test.go @@ -42,6 +42,8 @@ func TestDiscoveryDownloadRetriesTransientProxyFailure(t *testing.T) { {"recover", http.StatusServiceUnavailable, 2, 3, false}, {"bounded-retries", http.StatusServiceUnavailable, 9, 3, true}, {"permanent-not-found", http.StatusNotFound, 9, 1, true}, + {"closed-response-recovers", 0, 2, 3, false}, + {"closed-response-bounded", 0, 9, 3, true}, } { t.Run(test.name, func(t *testing.T) { var requests atomic.Int32 @@ -49,6 +51,15 @@ func TestDiscoveryDownloadRetriesTransientProxyFailure(t *testing.T) { switch r.URL.Path { case "/" + module + "/@v/" + version + ".info": if requests.Add(1) <= test.failures { + if test.status == 0 { + conn, _, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Errorf("hijack proxy response: %v", err) + return + } + conn.Close() + return + } http.Error(w, http.StatusText(test.status), test.status) return } @@ -74,7 +85,11 @@ func TestDiscoveryDownloadRetriesTransientProxyFailure(t *testing.T) { if (err != nil) != test.wantErr || requests.Load() != test.wantRuns { t.Fatalf("download requests=%d, error=%v; want requests=%d, error=%v", requests.Load(), err, test.wantRuns, test.wantErr) } - if err != nil && !strings.Contains(err.Error(), http.StatusText(test.status)) { + wantDiagnostic := http.StatusText(test.status) + if test.status == 0 { + wantDiagnostic = ": EOF" + } + if err != nil && !strings.Contains(err.Error(), wantDiagnostic) { t.Fatalf("lost final proxy diagnostic: %v", err) } if _, err := os.Stat(work); !os.IsNotExist(err) { @@ -124,3 +139,18 @@ func TestDiscoveryNetworkRetryKeepsDeadlineAndDeterministicFailures(t *testing.T } } } + +func TestDiscoveryNetworkRetriesHTTP2StreamError(t *testing.T) { + const diagnostic = "reading https://sum.golang.org/tile/8/0/x218/247: stream error: stream ID 13; INTERNAL_ERROR; received from peer" + attempts := 0 + err := retryDiscoveryGoNetwork(context.Background(), []time.Duration{0, 0}, func() error { + attempts++ + if attempts < 3 { + return errors.New(diagnostic) + } + return nil + }) + if err != nil || attempts != 3 { + t.Fatalf("HTTP/2 stream error did not recover after retry: attempts=%d error=%v", attempts, err) + } +} diff --git a/cmd/plan9asmcorpus/discovery_output_test.go b/cmd/plan9asmcorpus/discovery_output_test.go index 4a629990c..2975426fc 100644 --- a/cmd/plan9asmcorpus/discovery_output_test.go +++ b/cmd/plan9asmcorpus/discovery_output_test.go @@ -64,6 +64,78 @@ func TestDiscoveryAsmDeclRetryOutputOverflowIsNotIgnored(t *testing.T) { } } +func TestDiscoveryAsmDeclRetainsEarlyABIErrorInLargeDiagnostic(t *testing.T) { + for _, test := range []struct { + name string + diagnostic string + }{ + { + name: "ABI mismatch", + diagnostic: "source_amd64.s:10:1: [amd64] source: wrong argument size 16; expected $...-8", + }, + { + name: "infrastructure failure", + diagnostic: "go: write build cache: no space left on device", + }, + } { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, filepath.Join(dir, "go.mod"), "module example.com/pkg\n\ngo 1.20\n") + toolDir := t.TempDir() + source := filepath.Join(toolDir, "go.go") + program := `package main + +import ( + "bytes" + "os" +) + +func main() { + if len(os.Args) > 1 && os.Args[1] == "vet" { + _, _ = os.Stderr.WriteString(` + fmt.Sprintf("%q", test.diagnostic+"\n") + `) + _, _ = os.Stderr.Write(bytes.Repeat([]byte{'x'}, 70<<10)) + os.Exit(1) + } +} +` + writeTestFile(t, source, program) + name := "go" + if runtime.GOOS == "windows" { + name += ".exe" + } + command := exec.Command("go", "build", "-o", filepath.Join(toolDir, name), source) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("build fake Go tool: %v\n%s", err, output) + } + t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + err := runDiscoveryAsmDecl(context.Background(), dir, os.Environ(), "linux/amd64", nil, []string{"example.com/pkg"}) + if err == nil { + t.Fatal("early diagnostic disappeared after display truncation") + } + if !strings.Contains(discoveryCommandDiagnostic(err), test.diagnostic) { + t.Fatalf("full command diagnostic lost %q", test.diagnostic) + } + if strings.Contains(err.Error(), test.diagnostic) { + t.Fatal("display error unexpectedly retained the large diagnostic prefix") + } + if test.name == "ABI mismatch" { + rejected := discoveryAsmDeclRejectedFiles( + []string{"source_amd64.s", "other_amd64.s"}, discoveryCommandDiagnostic(err), + ) + if len(rejected) != 1 || rejected[0] != "source_amd64.s" { + t.Fatalf("ABI rejection affected wrong files: %v", rejected) + } + if evidence := discoveryAsmDeclRejectionEvidence(err, rejected); !strings.Contains(evidence, test.diagnostic) { + t.Fatalf("persisted ABI evidence lost the early mismatch: %q", evidence) + } + } else if !isDiscoveryInfrastructureFailure(err) { + t.Fatal("early resource failure was classified as source incompatibility") + } + }) + } +} + func buildDiscoveryOverflowGoTool(t *testing.T, vetFails bool) string { t.Helper() dir := t.TempDir() diff --git a/cmd/plan9asmcorpus/discovery_process_other.go b/cmd/plan9asmcorpus/discovery_process_other.go new file mode 100644 index 000000000..6338d9bf3 --- /dev/null +++ b/cmd/plan9asmcorpus/discovery_process_other.go @@ -0,0 +1,11 @@ +//go:build !unix + +package main + +import "os/exec" + +func runDiscoveryCommand(cmd *exec.Cmd) error { + // Unix process groups are unavailable here. Foreground Git maintenance + // and the shared WaitDelay still bound subprocess output-pipe waits. + return cmd.Run() +} diff --git a/cmd/plan9asmcorpus/discovery_process_unix.go b/cmd/plan9asmcorpus/discovery_process_unix.go new file mode 100644 index 000000000..974acb9f3 --- /dev/null +++ b/cmd/plan9asmcorpus/discovery_process_unix.go @@ -0,0 +1,36 @@ +//go:build unix + +package main + +import ( + "errors" + "fmt" + "os" + "os/exec" + "syscall" +) + +func runDiscoveryCommand(cmd *exec.Cmd) error { + // Own a fresh process group: canceling only Go leaves Git, compilers or + // LLVM children alive. Also stop descendants after ordinary parent exit, + // including children that closed their inherited output pipes. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + return stopDiscoveryCommandGroup(cmd) + } + err := cmd.Run() + if cmd.Process != nil { + if cleanupErr := stopDiscoveryCommandGroup(cmd); cleanupErr != nil && !errors.Is(cleanupErr, os.ErrProcessDone) { + err = errors.Join(err, fmt.Errorf("%w: %v", errDiscoveryCommandCleanup, cleanupErr)) + } + } + return err +} + +func stopDiscoveryCommandGroup(cmd *exec.Cmd) error { + err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + if errors.Is(err, syscall.ESRCH) { + return os.ErrProcessDone + } + return err +} diff --git a/cmd/plan9asmcorpus/discovery_process_unix_test.go b/cmd/plan9asmcorpus/discovery_process_unix_test.go new file mode 100644 index 000000000..64c94f225 --- /dev/null +++ b/cmd/plan9asmcorpus/discovery_process_unix_test.go @@ -0,0 +1,118 @@ +//go:build unix + +package main + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +// Model Go exiting while a Git subprocess still owns its private cache. +// Detached stdio also exercises descendants that Cmd.Wait cannot observe. +func TestDiscoveryCommandStopsCacheWriters(t *testing.T) { + const helperEnv = "PLAN9ASM_TEST_CACHE_WRITER" + if mode := os.Getenv(helperEnv); mode != "" { + runDiscoveryCacheWriterHelper(t, helperEnv, mode) + return + } + + for _, mode := range []string{"success", "failure", "cancel", "cancel-with-pipe"} { + t.Run(mode, func(t *testing.T) { + dir := t.TempDir() + ready := filepath.Join(dir, "ready") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + env := replaceEnv(os.Environ(), map[string]string{helperEnv: mode}) + done := make(chan error, 1) + go func() { + _, err := runCapturedCommandOutput(ctx, dir, env, os.Args[0], "-test.run=^TestDiscoveryCommandStopsCacheWriters$") + done <- err + }() + pid := waitDiscoveryCacheWriter(t, ready) + t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) }) + if strings.HasPrefix(mode, "cancel") { + cancel() + } + select { + case err := <-done: + if mode == "success" && err != nil { + t.Fatalf("successful command: %v", err) + } + if mode == "failure" { + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 17 { + t.Fatalf("lost command failure: %v", err) + } + } + if strings.HasPrefix(mode, "cancel") && !errors.Is(err, context.Canceled) { + t.Fatalf("lost cancellation: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("command cancellation left a descendant holding the output pipe") + } + + // Release the descendant only after command completion. A surviving + // process can now recreate cache files while the caller removes them. + writeTestFile(t, filepath.Join(dir, "release"), "") + time.Sleep(150 * time.Millisecond) + if _, err := os.Stat(filepath.Join(dir, "late-write")); !os.IsNotExist(err) { + t.Fatalf("descendant wrote cache after command returned: %v", err) + } + }) + } +} + +func waitDiscoveryCacheWriter(t *testing.T, ready string) int { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if data, err := os.ReadFile(ready); err == nil { + if pid, err := strconv.Atoi(string(data)); err == nil { + return pid + } + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("cache writer did not start") + return 0 +} + +func runDiscoveryCacheWriterHelper(t *testing.T, helperEnv, mode string) { + t.Helper() + if mode == "writer" { + writeTestFile(t, "ready", strconv.Itoa(os.Getpid())) + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat("release"); err == nil { + writeTestFile(t, "late-write", "cache changed") + os.Exit(0) + } + time.Sleep(5 * time.Millisecond) + } + os.Exit(0) + } + child := exec.Command(os.Args[0], "-test.run=^TestDiscoveryCommandStopsCacheWriters$") + child.Env = replaceEnv(os.Environ(), map[string]string{helperEnv: "writer"}) + if mode == "cancel-with-pipe" { + child.Stdout, child.Stderr = os.Stdout, os.Stderr + } + if err := child.Start(); err != nil { + t.Fatal(err) + } + waitDiscoveryCacheWriter(t, "ready") + if strings.HasPrefix(mode, "cancel") { + time.Sleep(5 * time.Second) + } + if mode == "failure" { + os.Exit(17) + } + os.Exit(0) +} diff --git a/cmd/plan9asmcorpus/discovery_progress.go b/cmd/plan9asmcorpus/discovery_progress.go index a9d080011..8a31d5c96 100644 --- a/cmd/plan9asmcorpus/discovery_progress.go +++ b/cmd/plan9asmcorpus/discovery_progress.go @@ -10,6 +10,7 @@ type discoveryCandidateProgress struct { InvalidSourceEvidence []discoveryInvalidMachineCodeEvidence `json:"invalid_source_evidence,omitempty"` Superseded *discoverySupersededSkip `json:"superseded,omitempty"` PrivateExtension *discoveryPrivateExtensionSkip `json:"private_extension,omitempty"` + NativeLayout *discoveryNativeLayoutSkip `json:"native_layout,omitempty"` } // This is a derived view, not a mutable flag attached to a scanned version. @@ -35,6 +36,7 @@ type discoveryProgress struct { SkippedInvalidSource int `json:"skipped_invalid_source"` SkippedSuperseded int `json:"skipped_superseded"` SkippedPrivateExtension int `json:"skipped_private_extension"` + SkippedNativeLayout int `json:"skipped_native_layout"` Pending int `json:"pending"` DiscoveredAsmFiles int `json:"discovered_asm_files"` Translations int `json:"translations"` @@ -54,6 +56,7 @@ func collectDiscoveryProgress(ledgerPath, reportsPath string, targets []string, var skips map[string]discoveryInvalidMachineCodeSkip var superseded map[string]discoverySupersededSkip var privateExtensions map[string]discoveryPrivateExtensionSkip + var nativeLayouts map[string]discoveryNativeLayoutSkip if len(repoRoot) == 1 { var err error skips, err = loadInvalidMachineCodeSkips(repoRoot[0]) @@ -77,6 +80,13 @@ func collectDiscoveryProgress(ledgerPath, reportsPath string, targets []string, if privateExtensions == nil { privateExtensions = map[string]discoveryPrivateExtensionSkip{} } + nativeLayouts, err = loadNativeLayoutSkips(repoRoot[0], ledgerPath) + if err != nil { + return discoveryProgress{}, err + } + if nativeLayouts == nil { + nativeLayouts = map[string]discoveryNativeLayoutSkip{} + } } progress := discoveryProgress{ SchemaVersion: 1, @@ -88,7 +98,7 @@ func collectDiscoveryProgress(ledgerPath, reportsPath string, targets []string, PendingShards: []int{}, Candidates: []discoveryCandidateProgress{}, } - if err := auditDiscoveryCorpusReports(ledgerPath, reportsPath, targets, source, skips, superseded, privateExtensions, &progress); err != nil { + if err := auditDiscoveryCorpusReports(ledgerPath, reportsPath, targets, source, skips, superseded, privateExtensions, nativeLayouts, &progress); err != nil { // Never return a plausible partial total after detecting corrupt evidence. return discoveryProgress{}, err } diff --git a/cmd/plan9asmcorpus/discovery_test.go b/cmd/plan9asmcorpus/discovery_test.go index 445922f12..a11aad94e 100644 --- a/cmd/plan9asmcorpus/discovery_test.go +++ b/cmd/plan9asmcorpus/discovery_test.go @@ -8,11 +8,15 @@ import ( "errors" "fmt" "go/build" + "net/http" + "net/http/httptest" "os" "path/filepath" "reflect" + "runtime" "strconv" "strings" + "sync/atomic" "testing" "time" ) @@ -631,6 +635,124 @@ func TestRunDiscoveryAsmDeclStillFindsABIMismatchWhenTestsDoNotCompile(t *testin } } +func TestRunDiscoveryAsmDeclTestOnlyProxyFailureDoesNotHideAssembly(t *testing.T) { + var requests atomic.Int32 + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + http.Error(w, "test-only dependency unavailable", http.StatusServiceUnavailable) + })) + defer proxy.Close() + + root := t.TempDir() + work := filepath.Join(root, "work") + module := filepath.Join(root, "module") + for _, dir := range []string{work, module} { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + } + writeTestFile(t, filepath.Join(work, "go.mod"), + "module plan9asm.local/probe\n\ngo 1.20\n\nrequire example.com/testonly v0.0.0\nreplace example.com/testonly => ../module\n") + writeTestFile(t, filepath.Join(module, "go.mod"), "module example.com/testonly\n\ngo 1.20\n") + writeTestFile(t, filepath.Join(module, "decl.go"), "package testonly\n\nfunc f() uint32\n") + writeTestFile(t, filepath.Join(module, "decl_test.go"), + "package testonly\n\nimport _ \"example.com/unavailable-test-dependency\"\n") + asm := filepath.Join(module, "decl_386.s") + writeTestFile(t, asm, "TEXT ·f(SB), $0-4\nMOVL AX, ret+0(FP)\nRET\n") + env := replaceEnv(os.Environ(), map[string]string{ + "GOFLAGS": "-mod=mod", "GOWORK": "off", "GOPROXY": proxy.URL, "GOSUMDB": "off", + }) + pattern := []string{"example.com/testonly"} + if err := runDiscoveryAsmDecl(context.Background(), work, env, "linux/386", nil, pattern); err != nil { + t.Fatalf("valid assembly rejected by test-only proxy failure: %v", err) + } + if requests.Load() == 0 { + t.Fatal("test dependency did not reach the failing proxy") + } + + writeTestFile(t, asm, "TEXT ·f(SB), $0-4\nMOVL AX, ret+4(FP)\nRET\n") + err := runDiscoveryAsmDecl(context.Background(), work, env, "linux/386", nil, pattern) + if err == nil || !isDiscoveryAsmDeclABIMismatch(discoveryCommandDiagnostic(err)) { + t.Fatalf("ABI mismatch hidden by test-only proxy failure: %v", err) + } + + writeTestFile(t, filepath.Join(module, "decl.go"), + "package testonly\n\nimport _ \"example.com/unavailable-production-dependency\"\n\nfunc f() uint32\n") + err = runDiscoveryAsmDecl(context.Background(), work, env, "linux/386", nil, pattern) + if !isDiscoveryInfrastructureFailure(err) { + t.Fatalf("production dependency failure was hidden: %v", err) + } +} + +func TestRunDiscoveryAsmDeclDoesNotAttributeDependencyABIToPackage(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "work") + module := filepath.Join(root, "module") + for _, name := range []string{dir, filepath.Join(module, "dep"), filepath.Join(module, "consumer")} { + if err := os.MkdirAll(name, 0755); err != nil { + t.Fatal(err) + } + } + writeTestFile(t, filepath.Join(dir, "go.mod"), + "module plan9asm.local/probe\n\ngo 1.20\n\nrequire example.com/asmdep v0.0.0\nreplace example.com/asmdep => ../module\n") + writeTestFile(t, filepath.Join(module, "go.mod"), "module example.com/asmdep\n\ngo 1.20\n") + writeTestFile(t, filepath.Join(module, "dep", "decl.go"), "package dep\n\nfunc bad() uint32\n") + writeTestFile(t, filepath.Join(module, "dep", "dep_amd64.s"), + "TEXT ·bad(SB), $0-4\nMOVL AX, ret+4(FP)\nRET\n") + writeTestFile(t, filepath.Join(module, "consumer", "decl.go"), + "package consumer\n\nfunc good(x uint32)\n") + writeTestFile(t, filepath.Join(module, "consumer", "decl_test.go"), + "package consumer\n\nimport _ \"example.com/asmdep/dep\"\n") + asm := filepath.Join(module, "consumer", "consumer_amd64.s") + writeTestFile(t, asm, "TEXT ·good(SB), $0-4\nMOVL x+0(FP), AX\nRET\n") + env := replaceEnv(os.Environ(), map[string]string{ + "GOFLAGS": "-mod=mod", "GOWORK": "off", "CGO_ENABLED": "0", + "GOOS": "linux", "GOARCH": "amd64", + }) + pattern := []string{"example.com/asmdep/consumer"} + _, depErr := runCapturedCommandOutput(context.Background(), dir, env, + "go", "vet", "-asmdecl", "example.com/asmdep/dep") + if depErr == nil { + t.Fatal("dependency fixture does not have an asmdecl mismatch") + } + _, rawErr := runCapturedCommandOutput(context.Background(), dir, env, + "go", "vet", "-asmdecl", pattern[0]) + if rawErr == nil && strings.HasPrefix(runtime.Version(), "go1.27") { + t.Fatalf("current Go did not reproduce the dependency ABI failure: %v", rawErr) + } + if rawErr != nil && !strings.Contains(discoveryCommandDiagnostic(rawErr), "dep_amd64.s") { + t.Fatalf("dependency ABI failure was not reproduced: %v", rawErr) + } + if err := runDiscoveryAsmDecl(context.Background(), dir, env, "linux/amd64", nil, pattern); err != nil { + t.Fatalf("dependency ABI failure was attributed to valid consumer: %v", err) + } + + writeTestFile(t, asm, "TEXT ·good(SB), $0-4\nMOVL x+4(FP), AX\nRET\n") + err := runDiscoveryAsmDecl(context.Background(), dir, env, "linux/amd64", nil, pattern) + if err == nil || !strings.Contains(discoveryCommandDiagnostic(err), "consumer_amd64.s") { + t.Fatalf("consumer ABI mismatch was hidden by dependency mismatch: %v", err) + } +} + +func TestDiscoveryTestlessAsmDeclRetryRequiresSelectedTests(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, filepath.Join(dir, "go.mod"), "module example.com/no-tests\n\ngo 1.20\n") + writeTestFile(t, filepath.Join(dir, "decl.go"), "package notests\n\nfunc f()\n") + writeTestFile(t, filepath.Join(dir, "decl_386.s"), "TEXT ·f(SB), $0-0\nRET\n") + env := replaceEnv(os.Environ(), map[string]string{ + "GOFLAGS": "-mod=mod", "GOWORK": "off", "CGO_ENABLED": "0", + "GOOS": "linux", "GOARCH": "386", + }) + err := runDiscoveryAsmDeclWithTestlessModuleCopies( + context.Background(), dir, env, + []string{"vet", "-asmdecl", "example.com/no-tests"}, + nil, []string{"example.com/no-tests"}, + ) + if err == nil { + t.Fatal("testless retry reported success without running vet") + } +} + func TestRunDiscoveryAsmDeclPropagatesExpiredContext(t *testing.T) { dir := t.TempDir() writeTestFile(t, filepath.Join(dir, "go.mod"), "module example.com/asmdecltimeout\n\ngo 1.20\n") @@ -660,6 +782,203 @@ func TestRunDiscoveryGoBuildChecksExactCurrentPackage(t *testing.T) { } } +func TestDiscoveryBatchChecksRealGoPackagesAndIsolatesInvalidSource(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, filepath.Join(dir, "go.mod"), "module example.com/batch\n\ngo 1.20\n") + for _, name := range []string{"first", "second"} { + if err := os.Mkdir(filepath.Join(dir, name), 0755); err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(dir, name, "decl.go"), + "package "+name+"\n\nfunc f()\n") + writeTestFile(t, filepath.Join(dir, name, "decl_amd64.s"), + "TEXT ·f(SB), $0-0\nRET\n") + } + groups := []discoveryPackageGroup{ + {Pattern: "example.com/batch/first"}, + {Pattern: "example.com/batch/second"}, + } + env := replaceEnv(os.Environ(), map[string]string{"GOFLAGS": "-mod=mod", "GOWORK": "off"}) + ctx := context.Background() + build := func(patterns []string) error { + return runDiscoveryGoBuild(ctx, dir, env, "linux/amd64", nil, patterns...) + } + vet := func(patterns []string) error { + return runDiscoveryAsmDecl(ctx, dir, env, "linux/amd64", nil, patterns) + } + for _, check := range []func([]string) error{build, vet} { + results, err := runDiscoveryPackageChecks(groups, check) + if err != nil || len(results) != 2 || results[0] != nil || results[1] != nil { + t.Fatalf("valid packages: results=%v error=%v", results, err) + } + } + + writeTestFile(t, filepath.Join(dir, "second", "decl.go"), + "package second\n\nfunc f(x int)\n") + results, err := runDiscoveryPackageChecks(groups, vet) + if err != nil || len(results) != 2 || results[0] != nil || results[1] == nil { + t.Fatalf("invalid second package ABI: results=%v error=%v", results, err) + } + + writeTestFile(t, filepath.Join(dir, "second", "decl.go"), + "package second\n\nvar broken = missingIdentifier\n") + results, err = runDiscoveryPackageChecks(groups, build) + if err != nil || len(results) != 2 || results[0] != nil || results[1] == nil { + t.Fatalf("invalid second package: results=%v error=%v", results, err) + } +} + +func TestDiscoveryPackageChecksBatchAndIsolateSourceFailures(t *testing.T) { + groups := []discoveryPackageGroup{ + {Pattern: "example.com/module/first"}, + {Pattern: "example.com/module/second"}, + {Pattern: "example.com/module/third"}, + } + sourceFailure := errors.New("second package has invalid source") + var calls [][]string + check := func(patterns []string) error { + calls = append(calls, append([]string(nil), patterns...)) + for _, pattern := range patterns { + if pattern == groups[1].Pattern { + return sourceFailure + } + } + return nil + } + + results, err := runDiscoveryPackageChecks(groups, check) + if err != nil { + t.Fatal(err) + } + if len(results) != len(groups) || results[0] != nil || + !errors.Is(results[1], sourceFailure) || results[2] != nil { + t.Fatalf("isolated package results = %v", results) + } + wantCalls := [][]string{ + {groups[0].Pattern, groups[1].Pattern, groups[2].Pattern}, + {groups[0].Pattern}, + {groups[1].Pattern}, + {groups[2].Pattern}, + } + if !reflect.DeepEqual(calls, wantCalls) { + t.Fatalf("package checks = %v, want %v", calls, wantCalls) + } +} + +func TestDiscoveryPackageChecksAvoidsPerPackageWorkAfterBatchPass(t *testing.T) { + groups := []discoveryPackageGroup{ + {Pattern: "example.com/module/first"}, + {Pattern: "example.com/module/second"}, + } + calls := 0 + results, err := runDiscoveryPackageChecks(groups, func(patterns []string) error { + calls++ + if len(patterns) != len(groups) { + t.Errorf("patterns = %v, want both packages", patterns) + } + return nil + }) + if err != nil || calls != 1 || len(results) != 2 || results[0] != nil || results[1] != nil { + t.Fatalf("batch success: calls=%d results=%v error=%v", calls, results, err) + } +} + +func TestDiscoveryPackageChecksDoesNotHideBatchInfrastructureFailure(t *testing.T) { + groups := []discoveryPackageGroup{ + {Pattern: "example.com/module/first"}, + {Pattern: "example.com/module/second"}, + } + infrastructure := errors.New("go build: signal: killed") + calls := 0 + results, err := runDiscoveryPackageChecks(groups, func([]string) error { + calls++ + return infrastructure + }) + if !errors.Is(err, infrastructure) || calls != 1 || results != nil { + t.Fatalf("batch infrastructure: calls=%d results=%v error=%v", calls, results, err) + } +} + +func TestDiscoveryPackageChecksUsesIndividualEvidenceForBatchOnlyFailure(t *testing.T) { + groups := []discoveryPackageGroup{ + {Pattern: "example.com/module/first"}, + {Pattern: "example.com/module/second"}, + } + calls := 0 + results, err := runDiscoveryPackageChecks(groups, func([]string) error { + calls++ + if calls == 1 { + return errors.New("batch-only failure") + } + return nil + }) + if err != nil || calls != 3 || len(results) != 2 || results[0] != nil || results[1] != nil { + t.Fatalf("individually verified batch failure: calls=%d results=%v error=%v", calls, results, err) + } +} + +func TestRunDiscoveryBoundedLimitsConcurrentPackages(t *testing.T) { + const packageCount = 6 + var running atomic.Int32 + var maximum atomic.Int32 + started := make(chan struct{}, packageCount) + release := make(chan struct{}) + done := make(chan error, 1) + go func() { + done <- runDiscoveryBounded(context.Background(), packageCount, 2, + func(context.Context, int) error { + active := running.Add(1) + for { + peak := maximum.Load() + if active <= peak || maximum.CompareAndSwap(peak, active) { + break + } + } + started <- struct{}{} + <-release + running.Add(-1) + return nil + }) + }() + for index := 0; index < 2; index++ { + select { + case <-started: + case <-time.After(5 * time.Second): + close(release) + t.Fatal("two independent packages never started together") + } + } + close(release) + if err := <-done; err != nil { + t.Fatal(err) + } + if got := maximum.Load(); got != 2 { + t.Fatalf("maximum simultaneous packages = %d, want 2", got) + } +} + +func TestRunDiscoveryBoundedCancelsPeersWithoutLosingFailure(t *testing.T) { + sentinel := errors.New("package translation failed") + secondStarted := make(chan struct{}) + err := runDiscoveryBounded(context.Background(), 3, 2, + func(ctx context.Context, index int) error { + switch index { + case 0: + <-secondStarted + return sentinel + case 1: + close(secondStarted) + <-ctx.Done() + return ctx.Err() + default: + return nil + } + }) + if !errors.Is(err, sentinel) { + t.Fatalf("bounded run error = %v, want original package failure", err) + } +} + func TestDiscoveryGoBuildInfrastructureFailuresAreNotSourceNotApplicable(t *testing.T) { for _, diagnostic := range []string{ "go build example.com/pkg: context deadline exceeded", @@ -745,6 +1064,108 @@ func TestDiscoveryAsmDeclOnlyClassifiesConcreteABIMismatches(t *testing.T) { } } +func TestDiscoveryAsmDeclRejectsOnlyFilesWithConcreteABIErrors(t *testing.T) { + diagnostic := strings.Join([]string{ + "pkg/first_amd64.s:3:1: [amd64] first: wrong argument size 16; expected $...-8", + "pkg/second_amd64.s:4:1: [amd64] second: unknown variable ret0; offset 8 is ret+8(FP)", + }, "\n") + files := []string{"pkg/first_amd64.s", "pkg/second_amd64.s"} + if got := discoveryAsmDeclRejectedFiles(files, diagnostic); !reflect.DeepEqual(got, files[:1]) { + t.Fatalf("ABI-rejected files = %v, want %v", got, files[:1]) + } + evidence := discoveryAsmDeclRejectionEvidence(errors.New(diagnostic), files[:1]) + if !strings.Contains(evidence, "first_amd64.s:3:1") || strings.Contains(evidence, "second_amd64.s") { + t.Fatalf("ABI evidence does not identify only the rejected file: %q", evidence) + } +} + +func TestDiscoveryAsmDeclInfrastructureCannotBecomeSourceNotApplicable(t *testing.T) { + failure := &discoveryCapturedCommandError{ + command: "go vet -asmdecl", + cause: errors.New("exit status 1"), + output: "go: write build cache: no space left on device\n" + strings.Repeat("x", 70<<10), + display: strings.Repeat("x", 64<<10), + } + rejected, err := classifyDiscoveryAsmDeclFailure([]string{"source_amd64.s"}, failure) + if err == nil || len(rejected) != 0 { + t.Fatalf("infrastructure failure became source N/A: rejected=%v error=%v", rejected, err) + } + unknownFile := errors.New("other_amd64.s:1:1: [amd64] other: wrong argument size 16; expected $...-8") + rejected, err = classifyDiscoveryAsmDeclFailure([]string{"source_amd64.s"}, unknownFile) + if err == nil || len(rejected) != 0 { + t.Fatalf("unmapped ABI failure became source N/A: rejected=%v error=%v", rejected, err) + } +} + +func TestDiscoveryAsmDeclAttributesCoSelectedFileWithoutRetranslatingIt(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"pkg", "other"} { + if err := os.Mkdir(filepath.Join(dir, name), 0755); err != nil { + t.Fatal(err) + } + } + writeTestFile(t, filepath.Join(dir, "pkg", "common_amd64.s"), + "//go:build !purego\n\nTEXT ·common(SB),0,$0-0\nRET\n") + writeTestFile(t, filepath.Join(dir, "pkg", "plugin_amd64.s"), + "//go:build plugin && !purego\n\nTEXT ·plugin(SB),0,$0-0\nRET\n") + writeTestFile(t, filepath.Join(dir, "other", "common_amd64.s"), + "TEXT ·other(SB),0,$0-0\nRET\n") + candidate := discoveryCandidate{ + AsmFiles: []string{ + "pkg/common_amd64.s", "pkg/plugin_amd64.s", "other/common_amd64.s", + }, + } + group := discoveryPackageGroup{ + Pattern: "example.com/test/pkg", AsmFiles: []string{"pkg/plugin_amd64.s"}, + } + files, err := discoveryAsmDeclPackageFiles(candidate, group, dir, "darwin/amd64", []string{"plugin"}) + if err != nil { + t.Fatal(err) + } + want := []string{"pkg/common_amd64.s", "pkg/plugin_amd64.s"} + if !reflect.DeepEqual(files, want) { + t.Fatalf("Go-selected asmdecl files = %v, want %v", files, want) + } + diagnostic := errors.New("pkg/common_amd64.s:12:1: [amd64] common: invalid MOVL of idx+16(FP); int is 8-byte value") + rejected, err := classifyDiscoveryAsmDeclFailure(files, diagnostic) + if err != nil || !reflect.DeepEqual(rejected, want[:1]) { + t.Fatalf("classified ABI files = %v, error = %v", rejected, err) + } + if remaining := subtractDiscoveryStrings(group.AsmFiles, rejected); !reflect.DeepEqual(remaining, group.AsmFiles) { + t.Fatalf("ABI error in co-selected source hid selected translation: %v", remaining) + } + unknown := errors.New("other/common_amd64.s:12:1: [amd64] other: invalid MOVL of idx+16(FP); int is 8-byte value") + if rejected, err := classifyDiscoveryAsmDeclFailure(files, unknown); err == nil || len(rejected) != 0 { + t.Fatalf("another package's ABI error became source N/A: rejected=%v error=%v", rejected, err) + } +} + +func TestDiscoveryAsmDeclForeignCandidateABIRequiresExactOtherFile(t *testing.T) { + files := []string{ + "internal/utils/cpuid_amd64.s", + "p503/arith_amd64.s", + } + packageFiles := files[1:] + foreign := errors.New("module-cache/example.com/asm@v1.0.0/internal/utils/cpuid_amd64.s:5:1: " + + "[amd64] cpuid: wrong argument size 4; expected $...-24\n" + + "module-cache/example.com/asm@v1.0.0/p503/arith_amd64.s:695:1: " + + "[amd64] mul: use of unnamed argument 0(FP)") + if !discoveryAsmDeclOnlyForeignCandidateABI(files, packageFiles, foreign) { + t.Fatal("exactly attributed other-package ABI error was not recognized") + } + for _, err := range []error{ + errors.New("module-cache/example.com/asm@v1.0.0/p503/arith_amd64.s:5:1: " + + "[amd64] mul: wrong argument size 4; expected $...-24"), + errors.New("other-module/cpuid_amd64.s:5:1: " + + "[amd64] cpuid: wrong argument size 4; expected $...-24"), + errors.New("go vet: no space left on device\n" + foreign.Error()), + } { + if discoveryAsmDeclOnlyForeignCandidateABI(files, packageFiles, err) { + t.Fatalf("unattributed or infrastructure error became ignorable: %v", err) + } + } +} + func TestDiscoveryConstraintTagsReadsOnlyTheSourceHeader(t *testing.T) { file := filepath.Join(t.TempDir(), "generated.go") contents := "//go:build realtag\n\npackage generated\n\nvar template = `\n//go:build invalid%template\n`\n" + strings.Repeat("x", 128<<10) diff --git a/cmd/plan9asmcorpus/embedded_alias.go b/cmd/plan9asmcorpus/embedded_alias.go new file mode 100644 index 000000000..556860370 --- /dev/null +++ b/cmd/plan9asmcorpus/embedded_alias.go @@ -0,0 +1,134 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "net/url" + "os" + "path" + "path/filepath" + "strings" + + "golang.org/x/mod/module" +) + +// An embedded-module alias resolves an old self-import to source already +// present inside the same checksum-verified module ZIP. It does not fetch a +// different upstream module or change the downloaded source tree. +type discoveryEmbeddedModuleAlias struct { + Module string `json:"module"` + Version string `json:"version"` + ModuleSum string `json:"module_sum"` + SourceDir string `json:"source_dir"` + ImportModule string `json:"import_module"` + EvidenceURL string `json:"evidence_url"` +} + +type discoveryEmbeddedModuleManifest struct { + SchemaVersion int `json:"schema_version"` + Aliases []discoveryEmbeddedModuleAlias `json:"aliases"` +} + +func loadDiscoveryEmbeddedModuleAliases(repoRoot string, candidates []discoveryCandidate) (map[string]discoveryEmbeddedModuleAlias, error) { + name := filepath.Join(repoRoot, "testdata", "corpus", "embedded-modules.json") + data, err := os.ReadFile(name) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + var manifest discoveryEmbeddedModuleManifest + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&manifest); err != nil { + return nil, fmt.Errorf("decode embedded-module aliases: %w", err) + } + if err := ensureJSONEOF(decoder); err != nil { + return nil, err + } + if manifest.SchemaVersion != 1 { + return nil, fmt.Errorf("unsupported embedded-module alias schema %d", manifest.SchemaVersion) + } + known := make(map[string]bool, len(candidates)) + for _, candidate := range candidates { + known[candidate.exactKey()] = true + } + aliases := make(map[string]discoveryEmbeddedModuleAlias, len(manifest.Aliases)) + for _, alias := range manifest.Aliases { + key := alias.Module + "@" + alias.Version + if !known[key] { + return nil, fmt.Errorf("embedded-module alias %s is absent from scan ledger", key) + } + if _, duplicate := aliases[key]; duplicate { + return nil, fmt.Errorf("duplicate embedded-module alias %s", key) + } + if err := module.Check(alias.Module, alias.Version); err != nil { + return nil, fmt.Errorf("%s: invalid module version: %w", key, err) + } + if err := module.CheckPath(alias.ImportModule); err != nil || alias.ImportModule == alias.Module { + return nil, fmt.Errorf("%s: invalid embedded-module import path %q", key, alias.ImportModule) + } + if !strings.HasPrefix(alias.ModuleSum, "h1:") { + return nil, fmt.Errorf("%s: invalid pinned module checksum", key) + } + digest, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(alias.ModuleSum, "h1:")) + if err != nil || len(digest) != 32 { + return nil, fmt.Errorf("%s: invalid pinned module checksum", key) + } + if !filepath.IsLocal(alias.SourceDir) || path.Clean(alias.SourceDir) != alias.SourceDir || + filepath.ToSlash(alias.SourceDir) != alias.SourceDir { + return nil, fmt.Errorf("%s: invalid embedded-module source directory %q", key, alias.SourceDir) + } + evidence, err := url.Parse(alias.EvidenceURL) + if err != nil || evidence.Scheme != "https" || evidence.Host == "" { + return nil, fmt.Errorf("%s: invalid embedded-module evidence URL", key) + } + aliases[key] = alias + } + return aliases, nil +} + +func prepareDiscoveryEmbeddedAlias(workDir string, download moduleDownloadInfo, alias discoveryEmbeddedModuleAlias) error { + if download.Path != alias.Module || download.Version != alias.Version || download.Sum != alias.ModuleSum { + return fmt.Errorf("embedded-module alias source does not match pinned module, version and checksum") + } + if !filepath.IsLocal(alias.SourceDir) || path.Clean(alias.SourceDir) != alias.SourceDir || + filepath.ToSlash(alias.SourceDir) != alias.SourceDir { + return fmt.Errorf("invalid embedded-module source directory %q", alias.SourceDir) + } + sourceDir := filepath.Join(download.Dir, filepath.FromSlash(alias.SourceDir)) + info, err := os.Lstat(sourceDir) + if err != nil { + return fmt.Errorf("inspect embedded-module source: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("embedded-module source %q is not a directory", alias.SourceDir) + } + if _, err := os.Stat(filepath.Join(sourceDir, "go.mod")); err == nil { + return fmt.Errorf("embedded-module source %q already contains go.mod", alias.SourceDir) + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect embedded-module go.mod: %w", err) + } + + aliasDir := filepath.Join(workDir, "embedded-module-alias") + if err := copyDiscoveryModuleTree(sourceDir, aliasDir); err != nil { + return fmt.Errorf("copy embedded-module source: %w", err) + } + goMod := fmt.Sprintf("module %s\n\ngo 1.27\n", alias.ImportModule) + if err := os.WriteFile(filepath.Join(aliasDir, "go.mod"), []byte(goMod), 0644); err != nil { + return fmt.Errorf("write embedded-module go.mod: %w", err) + } + return nil +} + +func addDiscoveryEmbeddedAliasToPlan(plan discoveryExecutionPlan, alias discoveryEmbeddedModuleAlias) (discoveryExecutionPlan, error) { + if alias.ImportModule == plan.ModulePath { + return discoveryExecutionPlan{}, fmt.Errorf("embedded-module import path cannot equal the candidate module path") + } + plan.GoMod += fmt.Sprintf("\nrequire %s v0.0.0\nreplace %s => ./embedded-module-alias\n", + alias.ImportModule, alias.ImportModule) + return plan, nil +} diff --git a/cmd/plan9asmcorpus/embedded_alias_test.go b/cmd/plan9asmcorpus/embedded_alias_test.go new file mode 100644 index 000000000..415c8bdd4 --- /dev/null +++ b/cmd/plan9asmcorpus/embedded_alias_test.go @@ -0,0 +1,183 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDiscoveryEmbeddedAliasManifestRequiresExactInspectedModule(t *testing.T) { + repoRoot := t.TempDir() + manifestDir := filepath.Join(repoRoot, "testdata", "corpus") + if err := os.MkdirAll(manifestDir, 0755); err != nil { + t.Fatal(err) + } + alias := discoveryEmbeddedModuleAlias{ + Module: "example.com/fork", + Version: "v1.2.3", + ModuleSum: "h1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + SourceDir: "nested", + ImportModule: "example.com/original", + EvidenceURL: "https://example.com/fork/tree/v1.2.3/nested", + } + writeManifest := func(aliases []discoveryEmbeddedModuleAlias) { + t.Helper() + data, err := json.Marshal(discoveryEmbeddedModuleManifest{ + SchemaVersion: 1, Aliases: aliases, + }) + if err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(manifestDir, "embedded-modules.json"), string(data)) + } + known := []discoveryCandidate{{Module: alias.Module, Version: alias.Version}} + writeManifest([]discoveryEmbeddedModuleAlias{alias}) + got, err := loadDiscoveryEmbeddedModuleAliases(repoRoot, known) + if err != nil || got[alias.Module+"@"+alias.Version] != alias { + t.Fatalf("valid embedded alias = %#v, %v", got, err) + } + + for _, change := range []struct { + name string + edit func(*discoveryEmbeddedModuleAlias) + }{ + {"unknown version", func(a *discoveryEmbeddedModuleAlias) { a.Version = "v1.2.4" }}, + {"checksum", func(a *discoveryEmbeddedModuleAlias) { a.ModuleSum = "h1:invalid" }}, + {"source escape", func(a *discoveryEmbeddedModuleAlias) { a.SourceDir = "../outside" }}, + {"same import module", func(a *discoveryEmbeddedModuleAlias) { a.ImportModule = a.Module }}, + {"non-HTTPS evidence", func(a *discoveryEmbeddedModuleAlias) { a.EvidenceURL = "http://example.com" }}, + } { + t.Run(change.name, func(t *testing.T) { + changed := alias + change.edit(&changed) + writeManifest([]discoveryEmbeddedModuleAlias{changed}) + if _, err := loadDiscoveryEmbeddedModuleAliases(repoRoot, known); err == nil { + t.Fatal("invalid embedded-module alias was accepted") + } + }) + } + writeManifest([]discoveryEmbeddedModuleAlias{alias, alias}) + if _, err := loadDiscoveryEmbeddedModuleAliases(repoRoot, known); err == nil { + t.Fatal("duplicate embedded-module alias was accepted") + } +} + +func TestCuratedEmbeddedAliasesMatchDiscoveryLedger(t *testing.T) { + repoRoot := filepath.Join("..", "..") + candidates, err := loadDiscoveryCandidates(filepath.Join(repoRoot, "testdata", "discovery", "ledger")) + if err != nil { + t.Fatal(err) + } + aliases, err := loadDiscoveryEmbeddedModuleAliases(repoRoot, candidates) + if err != nil { + t.Fatal(err) + } + if len(aliases) != 2 { + t.Fatalf("embedded aliases = %d, want 2", len(aliases)) + } + for _, modulePath := range []string{ + "github.com/EthanGYoung/ContainerFS", + "github.com/ethangyoung/containerfs", + } { + key := modulePath + "@v0.0.0-20190824181837-c8de36334a9e" + if aliases[key].SourceDir != "ContainerFS" { + t.Fatalf("missing exact embedded alias for %s", key) + } + } +} + +func TestDiscoveryEmbeddedAliasCopiesPinnedModuleSource(t *testing.T) { + moduleDir := t.TempDir() + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(moduleDir, "nested", "pkg", "dep"), 0755); err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(moduleDir, "nested", "pkg", "dep", "dep.go"), + "package dep\nconst Value = 42\n") + + alias := discoveryEmbeddedModuleAlias{ + Module: "example.com/fork", + Version: "v1.2.3", + ModuleSum: "h1:example-pinned-sum", + SourceDir: "nested", + ImportModule: "example.com/original", + } + download := moduleDownloadInfo{ + Path: alias.Module, Version: alias.Version, + Dir: moduleDir, Sum: alias.ModuleSum, + } + if err := prepareDiscoveryEmbeddedAlias(workDir, download, alias); err != nil { + t.Fatal(err) + } + copyDir := filepath.Join(workDir, "embedded-module-alias") + contents, err := os.ReadFile(filepath.Join(copyDir, "pkg", "dep", "dep.go")) + if err != nil || string(contents) != "package dep\nconst Value = 42\n" { + t.Fatalf("copied embedded package = %q, %v", contents, err) + } + goMod, err := os.ReadFile(filepath.Join(copyDir, "go.mod")) + if err != nil || string(goMod) != "module example.com/original\n\ngo 1.27\n" { + t.Fatalf("embedded go.mod = %q, %v", goMod, err) + } + if _, err := os.Stat(filepath.Join(moduleDir, "nested", "go.mod")); !os.IsNotExist(err) { + t.Fatalf("downloaded source was modified: %v", err) + } + + plan, err := makeDiscoveryExecutionPlan(discoveryCandidate{ + Module: alias.Module, Version: alias.Version, + }, alias.Module) + if err != nil { + t.Fatal(err) + } + plan, err = addDiscoveryEmbeddedAliasToPlan(plan, alias) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "require example.com/original v0.0.0", + "replace example.com/original => ./embedded-module-alias", + } { + if !strings.Contains(plan.GoMod, want) { + t.Fatalf("go.mod missing %q:\n%s", want, plan.GoMod) + } + } +} + +func TestDiscoveryEmbeddedAliasRejectsUnpinnedOrEscapingSource(t *testing.T) { + moduleDir := t.TempDir() + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(moduleDir, "nested"), 0755); err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(moduleDir, "nested", "dep.go"), "package nested\n") + alias := discoveryEmbeddedModuleAlias{ + Module: "example.com/fork", + Version: "v1.2.3", + ModuleSum: "h1:expected", + SourceDir: "nested", + ImportModule: "example.com/original", + } + download := moduleDownloadInfo{ + Path: alias.Module, Version: alias.Version, + Dir: moduleDir, Sum: "h1:changed", + } + if err := prepareDiscoveryEmbeddedAlias(workDir, download, alias); err == nil { + t.Fatal("mismatched module checksum was accepted") + } + if _, err := os.Stat(filepath.Join(workDir, "embedded-module-alias")); !os.IsNotExist(err) { + t.Fatalf("rejected alias created a workspace: %v", err) + } + + download.Sum = alias.ModuleSum + alias.SourceDir = "../outside" + if err := prepareDiscoveryEmbeddedAlias(workDir, download, alias); err == nil { + t.Fatal("escaping embedded source was accepted") + } + alias.SourceDir = "nested" + writeTestFile(t, filepath.Join(moduleDir, "nested", "go.mod"), + "module example.com/other\n") + if err := prepareDiscoveryEmbeddedAlias(workDir, download, alias); err == nil { + t.Fatal("nested module with an unrelated go.mod was overwritten") + } +} diff --git a/cmd/plan9asmcorpus/main.go b/cmd/plan9asmcorpus/main.go index 94df04141..dba3f67ad 100644 --- a/cmd/plan9asmcorpus/main.go +++ b/cmd/plan9asmcorpus/main.go @@ -56,6 +56,7 @@ type matrixReport struct { NotApplicableItems []matrixTargetNotApplicableItem `json:"not_applicable_items,omitempty"` SourceNotApplicableItems []discoverySourceNotApplicableItem `json:"source_not_applicable_items,omitempty"` PrivateExtension *discoveryPrivateExtensionSkip `json:"-"` + NativeLayout *discoveryNativeLayoutSkip `json:"-"` } const targetNotApplicableGoTextArgSize = "go_text_arg_size_mismatch" diff --git a/cmd/plan9asmcorpus/native_layout.go b/cmd/plan9asmcorpus/native_layout.go new file mode 100644 index 000000000..413e09af9 --- /dev/null +++ b/cmd/plan9asmcorpus/native_layout.go @@ -0,0 +1,314 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "strings" + + "github.com/xgo-dev/plan9asm/internal/gotoolchain" + "golang.org/x/mod/module" +) + +// A native-layout skip identifies source that depends on the exact bytes or +// offsets of a Go object. It is not a successful LLVM translation. +type discoveryNativeLayoutSkip struct { + Module string `json:"module"` + Version string `json:"version"` + AsmFile string `json:"asm_file"` + Targets []string `json:"targets"` + SourceSHA256 string `json:"source_sha256"` + Symbol string `json:"symbol"` + ObjectHex string `json:"object_hex"` + Reason string `json:"reason"` + EvidenceURLs []string `json:"evidence_urls"` +} + +type discoveryNativeLayoutManifest struct { + SchemaVersion int `json:"schema_version"` + Skips []discoveryNativeLayoutSkip `json:"skips"` +} + +var nativeLayoutSymbolPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z_0-9]*$`) + +func loadNativeLayoutSkips(repoRoot, ledgerPath string) (map[string]discoveryNativeLayoutSkip, error) { + name := filepath.Join(repoRoot, "testdata", "corpus", "native-layout.json") + data, err := os.ReadFile(name) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + var manifest discoveryNativeLayoutManifest + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&manifest); err != nil { + return nil, fmt.Errorf("decode native-layout manifest: %w", err) + } + if err := ensureJSONEOF(decoder); err != nil { + return nil, err + } + if manifest.SchemaVersion != 1 { + return nil, fmt.Errorf("unsupported native-layout manifest schema %d", manifest.SchemaVersion) + } + candidates, err := loadDiscoveryCandidates(ledgerPath) + if err != nil { + return nil, err + } + known := make(map[string]discoveryCandidate, len(candidates)) + for _, candidate := range candidates { + known[candidate.exactKey()] = candidate + } + skips := make(map[string]discoveryNativeLayoutSkip, len(manifest.Skips)) + for _, skip := range manifest.Skips { + key := skip.Module + "@" + skip.Version + if _, duplicate := skips[key]; duplicate { + return nil, fmt.Errorf("duplicate native-layout skip %s", key) + } + candidate, ok := known[key] + if !ok || !containsDiscoveryString(candidate.AsmFiles, skip.AsmFile) { + return nil, fmt.Errorf("%s: native-layout file %q is absent from scan ledger", key, skip.AsmFile) + } + if err := module.Check(skip.Module, skip.Version); err != nil { + return nil, fmt.Errorf("%s: invalid module version: %w", key, err) + } + if !filepath.IsLocal(skip.AsmFile) || path.Clean(skip.AsmFile) != skip.AsmFile || + filepath.ToSlash(skip.AsmFile) != skip.AsmFile || !strings.HasSuffix(skip.AsmFile, ".s") { + return nil, fmt.Errorf("%s: invalid native-layout assembly path %q", key, skip.AsmFile) + } + if len(skip.Targets) == 0 { + return nil, fmt.Errorf("%s: native-layout skip has no targets", key) + } + seenTargets := make(map[string]bool, len(skip.Targets)) + for _, target := range skip.Targets { + if err := validateTarget(target); err != nil { + return nil, fmt.Errorf("%s: invalid native-layout target: %w", key, err) + } + if seenTargets[target] { + return nil, fmt.Errorf("%s: duplicate native-layout target %s", key, target) + } + seenTargets[target] = true + } + if !discoverySHA256Pattern.MatchString(skip.SourceSHA256) { + return nil, fmt.Errorf("%s: invalid native-layout source SHA-256", key) + } + if !nativeLayoutSymbolPattern.MatchString(skip.Symbol) { + return nil, fmt.Errorf("%s: invalid native-layout symbol %q", key, skip.Symbol) + } + if len(skip.ObjectHex) < 2 || len(skip.ObjectHex)%2 != 0 || + strings.ToLower(skip.ObjectHex) != skip.ObjectHex { + return nil, fmt.Errorf("%s: invalid native-layout object bytes %q", key, skip.ObjectHex) + } + if _, err := hex.DecodeString(skip.ObjectHex); err != nil { + return nil, fmt.Errorf("%s: invalid native-layout object bytes: %w", key, err) + } + if strings.TrimSpace(skip.Reason) == "" || len(skip.EvidenceURLs) == 0 { + return nil, fmt.Errorf("%s: native-layout skip lacks reason or evidence", key) + } + for _, rawURL := range skip.EvidenceURLs { + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return nil, fmt.Errorf("%s: invalid native-layout evidence URL %q", key, rawURL) + } + } + skips[key] = skip + } + return skips, nil +} + +func verifyNativeLayoutSource(moduleDir string, skip discoveryNativeLayoutSkip) error { + if !filepath.IsLocal(skip.AsmFile) { + return fmt.Errorf("native-layout assembly path is not local: %q", skip.AsmFile) + } + name := filepath.Join(moduleDir, filepath.FromSlash(skip.AsmFile)) + data, err := os.ReadFile(name) + if err != nil { + return fmt.Errorf("read native-layout assembly %s: %w", skip.AsmFile, err) + } + digest := sha256.Sum256(data) + if got := hex.EncodeToString(digest[:]); got != skip.SourceSHA256 { + return fmt.Errorf("native-layout source SHA-256 mismatch for %s: got %s", skip.AsmFile, got) + } + return nil +} + +func verifyNativeLayoutGoObject( + ctx context.Context, + workDir, moduleDir string, + env []string, + skip discoveryNativeLayoutSkip, +) error { + for _, target := range skip.Targets { + if err := verifyNativeLayoutGoObjectTarget(ctx, workDir, moduleDir, env, skip, target); err != nil { + return err + } + } + return nil +} + +func verifyNativeLayoutGoObjectTarget( + ctx context.Context, + workDir, moduleDir string, + env []string, + skip discoveryNativeLayoutSkip, + target string, +) error { + goos, goarch, ok := strings.Cut(target, "/") + if !ok { + return fmt.Errorf("invalid native-layout target %q", target) + } + source := filepath.Join(moduleDir, filepath.FromSlash(skip.AsmFile)) + object := filepath.Join(workDir, "native-layout-source.o") + includeDir := filepath.Dir(source) + goRoot, err := gotoolchain.Root() + if err != nil { + return err + } + targetEnv := replaceEnv(env, map[string]string{ + "GOOS": goos, "GOARCH": goarch, "CGO_ENABLED": "0", + }) + if err := runCapturedCommand(ctx, workDir, targetEnv, "go", + "tool", "asm", "-I", includeDir, "-I", filepath.Join(goRoot, "pkg", "include"), + "-o", object, source); err != nil { + return fmt.Errorf("current Go assembler rejects native-layout source %s on %s: %w", + skip.AsmFile, target, err) + } + defer os.Remove(object) + output, err := runCapturedCommandOutput(ctx, workDir, targetEnv, "go", "tool", "objdump", "-s", skip.Symbol, object) + if err != nil { + return fmt.Errorf("disassemble native-layout source %s on %s: %w", skip.AsmFile, target, err) + } + objectBytes, err := nativeLayoutSymbolBytes(output, skip.Symbol) + if err != nil { + return fmt.Errorf("native-layout source %s on %s: %w", skip.AsmFile, target, err) + } + witness, _ := hex.DecodeString(skip.ObjectHex) + if !bytes.Contains(objectBytes, witness) { + return fmt.Errorf("native-layout object bytes %s absent from symbol %s", skip.ObjectHex, skip.Symbol) + } + return nil +} + +func nativeLayoutSymbolBytes(output []byte, symbol string) ([]byte, error) { + var objectBytes []byte + within := false + found := false + scanner := bufio.NewScanner(bytes.NewReader(output)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "TEXT ") { + if within { + break + } + fields := strings.Fields(line) + within = len(fields) >= 2 && strings.HasSuffix(fields[1], "."+symbol+"(SB)") + found = found || within + continue + } + if !within { + continue + } + fields := strings.Fields(line) + if len(fields) < 3 || !strings.HasPrefix(fields[1], "0x") { + continue + } + instruction, err := hex.DecodeString(fields[2]) + if err != nil { + return nil, fmt.Errorf("invalid Go objdump bytes %q: %w", fields[2], err) + } + objectBytes = append(objectBytes, instruction...) + } + if err := scanner.Err(); err != nil { + return nil, err + } + if !found || len(objectBytes) == 0 { + return nil, fmt.Errorf("Go objdump has no bytes for symbol %s", symbol) + } + return objectBytes, nil +} + +func filterNativeLayoutConfigurations( + configs []discoveryBuildConfiguration, + skip discoveryNativeLayoutSkip, +) ([]discoveryBuildConfiguration, bool, error) { + selectedTargets := make(map[string]bool) + for _, config := range configs { + if !containsDiscoveryString(config.AsmFiles, skip.AsmFile) { + continue + } + for _, target := range config.Targets { + selectedTargets[target] = true + } + } + pinnedTargets := make(map[string]bool, len(skip.Targets)) + for _, target := range skip.Targets { + pinnedTargets[target] = true + } + allPinned := len(selectedTargets) == len(pinnedTargets) + for target := range selectedTargets { + if !pinnedTargets[target] { + allPinned = false + } + } + if !allPinned { + return nil, false, fmt.Errorf( + "native-layout source %s selects targets %v, pinned targets %v", + skip.AsmFile, sortedDiscoverySet(selectedTargets), sortedDiscoverySet(pinnedTargets), + ) + } + for _, target := range skip.Targets { + filtered, active, err := filterExactAssemblyConfiguration(configs, skip.AsmFile, target, "native-layout") + if err != nil || !active { + return nil, active, err + } + configs = filtered + } + return configs, true, nil +} + +func nativeLayoutSkipMatchesResult(skip discoveryNativeLayoutSkip, result discoveryCorpusResult) bool { + if skip.Module == "" || result.NativeLayout == nil { + return false + } + actual, err := json.Marshal(result.NativeLayout) + if err != nil { + return false + } + want, err := json.Marshal(skip) + return err == nil && bytes.Equal(actual, want) +} + +func validateNativeLayoutResult(result discoveryCorpusResult) error { + skip := result.NativeLayout + if skip == nil || skip.Module != result.Module || skip.Version != result.Version || + result.Error != "" || !containsDiscoveryString(result.DiscoveredAsmFiles, skip.AsmFile) || + strings.TrimSpace(skip.Reason) == "" || skip.SourceSHA256 == "" || + skip.Symbol == "" || skip.ObjectHex == "" { + return fmt.Errorf("invalid native-layout skip evidence") + } + expectedTranslations := 0 + for _, config := range result.BuildConfigurations { + for _, target := range skip.Targets { + if containsDiscoveryString(config.Targets, target) && + containsDiscoveryString(config.AsmFiles, skip.AsmFile) { + return fmt.Errorf("native-layout file %s on %s was also claimed as translated", skip.AsmFile, target) + } + } + expectedTranslations += len(config.Targets) * len(config.AsmFiles) + } + if result.Translations+result.NotApplicableTranslations != expectedTranslations || + !equalDiscoveryStrings(result.ApplicableAsmFiles, discoveryConfigurationAsmFiles(result.BuildConfigurations)) { + return fmt.Errorf("native-layout result does not account for every remaining applicable file and target") + } + return nil +} diff --git a/cmd/plan9asmcorpus/native_layout_test.go b/cmd/plan9asmcorpus/native_layout_test.go new file mode 100644 index 000000000..ad6a96441 --- /dev/null +++ b/cmd/plan9asmcorpus/native_layout_test.go @@ -0,0 +1,258 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNativeLayoutManifestPinsExactCandidates(t *testing.T) { + root := filepath.Join("..", "..") + ledger := filepath.Join(root, "testdata", "discovery", "ledger") + skips, err := loadNativeLayoutSkips(root, ledger) + if err != nil { + t.Fatal(err) + } + if len(skips) != 4 { + t.Fatalf("native-layout skips = %d, want four exact candidates", len(skips)) + } + for _, key := range []string{ + "github.com/quasilyte/GopherJRE@v0.0.0-20250131090304-888098460476", + "github.com/aabalke/gojit@v0.0.0-20260919011104-e8574bd413ce", + "github.com/LamkasDev/sharkie@v0.0.0-20260919122514-0af66a893005", + "github.com/lamkasdev/sharkie@v0.0.0-20260919122514-0af66a893005", + } { + if skips[key].AsmFile == "" || skips[key].ObjectHex == "" { + t.Fatalf("missing exact native-layout evidence for %s", key) + } + } +} + +func TestNativeLayoutProofRequiresSourceAndGoObjectBytes(t *testing.T) { + dir := t.TempDir() + work := t.TempDir() + const source = "#include \"textflag.h\"\nTEXT ·raw(SB),NOSPLIT,$0-0\nBYTE $0x90\nRET\n" + writeTestFile(t, filepath.Join(dir, "raw_amd64.s"), source) + digest := sha256.Sum256([]byte(source)) + skip := discoveryNativeLayoutSkip{ + Module: "example.com/raw", Version: "v1.0.0", + AsmFile: "raw_amd64.s", Targets: []string{"linux/amd64"}, + SourceSHA256: hex.EncodeToString(digest[:]), + Symbol: "raw", ObjectHex: "90", + Reason: "byte-exact native entry", EvidenceURLs: []string{"https://example.com/raw"}, + } + if err := verifyNativeLayoutSource(dir, skip); err != nil { + t.Fatal(err) + } + if err := verifyNativeLayoutGoObject(context.Background(), work, dir, os.Environ(), skip); err != nil { + t.Fatal(err) + } + skip.ObjectHex = "deaddead" + if err := verifyNativeLayoutGoObject(context.Background(), work, dir, os.Environ(), skip); err == nil { + t.Fatal("absent native object bytes were accepted") + } + skip.ObjectHex = "90" + writeTestFile(t, filepath.Join(dir, "raw_amd64.s"), source+"\n") + if err := verifyNativeLayoutSource(dir, skip); err == nil { + t.Fatal("changed source was accepted") + } +} + +func TestNativeLayoutWitnessMustBelongToPinnedSymbol(t *testing.T) { + dir := t.TempDir() + work := t.TempDir() + const source = "#include \"textflag.h\"\n" + + "TEXT ·raw(SB),NOSPLIT,$0-0\nRET\n" + + "TEXT ·other(SB),NOSPLIT,$0-0\nBYTE $0x90\nRET\n" + writeTestFile(t, filepath.Join(dir, "raw_amd64.s"), source) + digest := sha256.Sum256([]byte(source)) + skip := discoveryNativeLayoutSkip{ + AsmFile: "raw_amd64.s", Targets: []string{"linux/amd64"}, + SourceSHA256: hex.EncodeToString(digest[:]), + Symbol: "raw", ObjectHex: "90", + } + if err := verifyNativeLayoutGoObject(context.Background(), work, dir, os.Environ(), skip); err == nil { + t.Fatal("witness from another TEXT was accepted") + } +} + +func TestNativeLayoutFiltersOnlyPinnedFileAndTarget(t *testing.T) { + skip := discoveryNativeLayoutSkip{ + AsmFile: "jit_amd64.s", Targets: []string{"darwin/amd64"}, + } + configs := []discoveryBuildConfiguration{{ + Targets: []string{"darwin/amd64"}, + AsmFiles: []string{"jit_amd64.s", "helper_amd64.s"}, + }, { + Targets: []string{"linux/amd64"}, + AsmFiles: []string{"helper_amd64.s"}, + }} + filtered, active, err := filterNativeLayoutConfigurations(configs, skip) + if err != nil || !active || len(filtered) != 2 { + t.Fatalf("filtered = %+v, active=%t, err=%v", filtered, active, err) + } + for _, config := range filtered { + if containsDiscoveryString(config.Targets, skip.Targets[0]) && + containsDiscoveryString(config.AsmFiles, skip.AsmFile) { + t.Fatal("pinned file and target remained selected") + } + } + if !containsDiscoveryString(filtered[0].AsmFiles, "helper_amd64.s") || + !containsDiscoveryString(filtered[1].AsmFiles, "helper_amd64.s") { + t.Fatal("other files or targets were excluded") + } +} + +func TestNativeLayoutFiltersEveryPinnedTarget(t *testing.T) { + skip := discoveryNativeLayoutSkip{ + AsmFile: "jit_amd64.s", + Targets: []string{"darwin/amd64", "linux/amd64", "windows/amd64"}, + } + configs := []discoveryBuildConfiguration{{ + Targets: []string{"darwin/amd64", "linux/amd64", "windows/amd64"}, + AsmFiles: []string{"jit_amd64.s", "helper_amd64.s"}, + }} + filtered, active, err := filterNativeLayoutConfigurations(configs, skip) + if err != nil || !active { + t.Fatalf("filter multiple native targets: active=%t error=%v", active, err) + } + for _, config := range filtered { + for _, target := range skip.Targets { + if containsDiscoveryString(config.Targets, target) && + containsDiscoveryString(config.AsmFiles, skip.AsmFile) { + t.Fatalf("pinned file remains on %s: %+v", target, config) + } + } + } + if !containsDiscoveryString(discoveryConfigurationAsmFiles(filtered), "helper_amd64.s") { + t.Fatal("unrelated assembly was excluded") + } +} + +func TestNativeLayoutRequiresEverySelectedTarget(t *testing.T) { + skip := discoveryNativeLayoutSkip{ + AsmFile: "jit_amd64.s", + Targets: []string{"darwin/amd64"}, + } + configs := []discoveryBuildConfiguration{{ + Targets: []string{"darwin/amd64", "linux/amd64"}, + AsmFiles: []string{"jit_amd64.s"}, + }} + if _, _, err := filterNativeLayoutConfigurations(configs, skip); err == nil { + t.Fatal("native-layout source remained translatable on an unpinned target") + } +} + +func TestNativeLayoutCandidateIsNotPassed(t *testing.T) { + skip := discoveryNativeLayoutSkip{ + Module: "example.com/asm", Version: "v1.0.0", + AsmFile: "jit_amd64.s", Targets: []string{"darwin/amd64"}, + SourceSHA256: strings.Repeat("a", 64), Symbol: "jit", ObjectHex: "90", + Reason: "byte-exact entry", EvidenceURLs: []string{"https://example.com/source"}, + } + result := discoveryCorpusResult{ + Module: skip.Module, Version: skip.Version, + Status: discoveryStatusSkippedNativeLayout, + DiscoveredAsmFiles: []string{skip.AsmFile, "helper_amd64.s"}, + ApplicableAsmFiles: []string{"helper_amd64.s"}, + BuildConfigurations: []discoveryBuildConfiguration{{ + Targets: []string{"darwin/amd64"}, AsmFiles: []string{"helper_amd64.s"}, + }}, + Translations: 1, NativeLayout: &skip, + } + report := discoveryCorpusReport{ + Selected: 1, SkippedNativeLayout: 1, Translations: 1, + Results: []discoveryCorpusResult{result}, + } + if err := validateDiscoveryCorpusAccounting(report); err != nil { + t.Fatal(err) + } + if report.Passed != 0 { + t.Fatal("native-layout candidate counted as passed") + } + report.Results[0].BuildConfigurations[0].AsmFiles = append( + report.Results[0].BuildConfigurations[0].AsmFiles, skip.AsmFile, + ) + if err := validateDiscoveryCorpusAccounting(report); err == nil { + t.Fatal("native-layout file was also claimed as translated") + } + report.Results[0].BuildConfigurations[0].AsmFiles = []string{"helper_amd64.s"} + report.Results[0].Translations = 2 + report.Translations = 2 + if err := validateDiscoveryCorpusAccounting(report); err == nil { + t.Fatal("native-layout result claimed an untested translation") + } +} + +func TestNativeLayoutSkipSurvivesAuditedAssemblyLedger(t *testing.T) { + ledger, reports, source := writeDiscoveryReportFixture(t) + root := t.TempDir() + manifestDir := filepath.Join(root, "testdata", "corpus") + if err := os.MkdirAll(manifestDir, 0755); err != nil { + t.Fatal(err) + } + skip := discoveryNativeLayoutSkip{ + Module: "example.com/a", Version: "v1.0.0", + AsmFile: "a_amd64.s", Targets: []string{"linux/amd64"}, + SourceSHA256: strings.Repeat("a", 64), Symbol: "a", ObjectHex: "90", + Reason: "native layout", EvidenceURLs: []string{"https://example.com/source"}, + } + manifest := discoveryNativeLayoutManifest{SchemaVersion: 1, Skips: []discoveryNativeLayoutSkip{skip}} + writeManifest := func() { + t.Helper() + data, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(manifestDir, "native-layout.json"), string(data)) + } + writeManifest() + if _, err := collectDiscoveryProgress(ledger, reports, + []string{"linux/amd64", "linux/arm64"}, source, 2, root); err == nil { + t.Fatal("pinned native-layout candidate was accepted as passed") + } + for shard := 0; shard < 2; shard++ { + name := filepath.Join(reports, "shard-"+string(rune('0'+shard))+".json") + report, err := readDiscoveryCorpusReport(name) + if err != nil { + t.Fatal(err) + } + for i := range report.Results { + if report.Results[i].Module != skip.Module { + continue + } + report.Results[i].Status = discoveryStatusSkippedNativeLayout + report.Results[i].Translations = 0 + report.Results[i].NativeLayout = &skip + report.Passed-- + report.Translations-- + report.SkippedNativeLayout++ + if err := writeDiscoveryCorpusReport(name, report); err != nil { + t.Fatal(err) + } + } + } + progress, err := collectDiscoveryProgress(ledger, reports, []string{"linux/amd64", "linux/arm64"}, source, 2, root) + if err != nil || !progress.Verified || progress.Passed != 1 || progress.SkippedNativeLayout != 1 { + t.Fatalf("native-layout progress = %+v, %v", progress, err) + } + output := filepath.Join(t.TempDir(), "assembly-ledger") + if err := writeAssemblyLedger(output, progress, strings.Repeat("c", 64)); err != nil { + t.Fatal(err) + } + restored, err := readAssemblyLedger(output, progress.LedgerSHA256, strings.Repeat("c", 64)) + if err != nil || restored.SkippedNativeLayout != 1 { + t.Fatalf("restored native-layout progress = %+v, %v", restored, err) + } + manifest.Skips[0].ObjectHex = "91" + writeManifest() + if _, err := collectDiscoveryProgress(ledger, reports, + []string{"linux/amd64", "linux/arm64"}, source, 2, root); err == nil { + t.Fatal("report with stale native-layout manifest was accepted") + } +} diff --git a/cmd/plan9asmcorpus/private_extension.go b/cmd/plan9asmcorpus/private_extension.go index bfec0c917..fee639ab1 100644 --- a/cmd/plan9asmcorpus/private_extension.go +++ b/cmd/plan9asmcorpus/private_extension.go @@ -168,30 +168,37 @@ func verifyPrivateExtensionGoAssembler( func filterPrivateExtensionConfigurations( configs []discoveryBuildConfiguration, skip discoveryPrivateExtensionSkip, +) ([]discoveryBuildConfiguration, bool, error) { + return filterExactAssemblyConfiguration(configs, skip.AsmFile, skip.Target, "private-extension") +} + +func filterExactAssemblyConfiguration( + configs []discoveryBuildConfiguration, + asmFile, target, kind string, ) ([]discoveryBuildConfiguration, bool, error) { filtered := make([]discoveryBuildConfiguration, 0, len(configs)+1) active := false for _, config := range configs { - if !containsDiscoveryString(config.Targets, skip.Target) || !containsDiscoveryString(config.AsmFiles, skip.AsmFile) { + if !containsDiscoveryString(config.Targets, target) || !containsDiscoveryString(config.AsmFiles, asmFile) { filtered = append(filtered, config) continue } if active { return nil, false, fmt.Errorf( - "private-extension file %s on %s occurs in multiple build configurations", - skip.AsmFile, skip.Target, + "%s file %s on %s occurs in multiple build configurations", + kind, asmFile, target, ) } active = true - remainingFiles := subtractDiscoveryStrings(config.AsmFiles, []string{skip.AsmFile}) + remainingFiles := subtractDiscoveryStrings(config.AsmFiles, []string{asmFile}) if len(remainingFiles) != 0 { filtered = append(filtered, discoveryBuildConfiguration{ BuildTags: append([]string(nil), config.BuildTags...), - Targets: []string{skip.Target}, + Targets: []string{target}, AsmFiles: remainingFiles, }) } - remainingTargets := subtractDiscoveryStrings(config.Targets, []string{skip.Target}) + remainingTargets := subtractDiscoveryStrings(config.Targets, []string{target}) if len(remainingTargets) != 0 { filtered = append(filtered, discoveryBuildConfiguration{ BuildTags: append([]string(nil), config.BuildTags...), diff --git a/cmd/plan9asmll/main.go b/cmd/plan9asmll/main.go index 53ff8046c..f4ad24f65 100644 --- a/cmd/plan9asmll/main.go +++ b/cmd/plan9asmll/main.go @@ -1427,19 +1427,7 @@ func tupleRetType(ts []plan9asm.LLVMType) plan9asm.LLVMType { } func splitSymPlusOff(s string) (base string, off int64) { - s = strings.TrimSpace(s) - if s == "" { - return "", 0 - } - sep := strings.LastIndexAny(s, "+-") - if sep <= 0 || sep == len(s)-1 { - return s, 0 - } - n, err := strconv.ParseInt(strings.TrimSpace(s[sep:]), 0, 64) - if err != nil { - return s, 0 - } - return strings.TrimSpace(s[:sep]), n + return plan9asm.SplitSymbolOffset(s) } func linknameRemoteToLocal(files []*ast.File) map[string]string { diff --git a/cmd/plan9asmll/main_test.go b/cmd/plan9asmll/main_test.go index 2c10b413d..e1fc7543a 100644 --- a/cmd/plan9asmll/main_test.go +++ b/cmd/plan9asmll/main_test.go @@ -1565,7 +1565,7 @@ func TestExtractSupportedOpsFindsCompleteARM64AddedFamilies(t *testing.T) { "ZFDIV", "ZFDIVR", "ZFSCALE", "ZSUQADD", "ZUSQADD", "ZFCADD", - "ZDUPW", + "ZDUP", "ZDUPW", "ZDUPQ", "ZDUPM", "ZFDOT", diff --git a/cmd/plan9asmll/symbol_offset_test.go b/cmd/plan9asmll/symbol_offset_test.go new file mode 100644 index 000000000..0fe41428f --- /dev/null +++ b/cmd/plan9asmll/symbol_offset_test.go @@ -0,0 +1,23 @@ +package main + +import "testing" + +func TestSplitSymbolOffsetExpressions(t *testing.T) { + tests := []struct { + input string + name string + off int64 + }{ + {"data+ 8", "data", 8}, + {"data - 8", "data", -8}, + {"data+(4*2)", "data", 8}, + {"data+8-4", "data", 4}, + {"data-name", "data-name", 0}, + } + for _, tt := range tests { + name, off := splitSymPlusOff(tt.input) + if name != tt.name || off != tt.off { + t.Errorf("splitSymPlusOff(%q) = %q, %d; want %q, %d", tt.input, name, off, tt.name, tt.off) + } + } +} diff --git a/cross_runtime_test.go b/cross_runtime_test.go index f72e1e7b9..36882af34 100644 --- a/cross_runtime_test.go +++ b/cross_runtime_test.go @@ -227,6 +227,24 @@ func TestCrossLinuxRuntimeMatrix(t *testing.T) { compileAndRunRuntimeTestWithCompiler(t, llc, []string{"aarch64-linux-gnu-gcc", "-march=armv8.2-a+sve"}, "pool_sve_flags", triple, ir, main, []string{"qemu-aarch64", "-cpu", "max,sve-max-vq=16", "-L", "/usr/aarch64-linux-gnu"}) }) + t.Run("arm64_sve_pool_contiguous", func(t *testing.T) { + const triple = "aarch64-unknown-linux-gnu" + ir, main := arm64RawPoolSVEContiguousRuntime(t, triple) + compileAndRunRuntimeTestWithCompiler(t, llc, []string{"aarch64-linux-gnu-gcc", "-march=armv8.2-a+sve"}, "pool_sve_contiguous", triple, ir, main, + []string{"qemu-aarch64", "-cpu", "max,sve-max-vq=16", "-L", "/usr/aarch64-linux-gnu"}) + }) + t.Run("arm64_sve_dup_indexed", func(t *testing.T) { + const triple = "aarch64-unknown-linux-gnu" + ir, main := arm64SVEDupIndexedRuntime(t, triple) + compileAndRunRuntimeTestWithCompiler(t, llc, []string{"aarch64-linux-gnu-gcc", "-march=armv8.2-a+sve"}, "dup_indexed", triple, ir, main, + []string{"qemu-aarch64", "-cpu", "max,sve-max-vq=16", "-L", "/usr/aarch64-linux-gnu"}) + }) + t.Run("arm64_raw_pool_masked", func(t *testing.T) { + const triple = "aarch64-unknown-linux-gnu" + ir := arm64RawPoolMaskedIR(t, triple) + compileAndRunRuntimeTestWithCompiler(t, llc, []string{"aarch64-linux-gnu-gcc"}, "pool_masked", triple, ir, arm64RawPoolMaskedMain, + []string{"qemu-aarch64", "-L", "/usr/aarch64-linux-gnu"}) + }) t.Run("arm64_raw_pool_counter_loops", func(t *testing.T) { const triple = "aarch64-unknown-linux-gnu" ir := arm64RawPoolLoopIR(t, triple) diff --git a/docs/development/current-work.md b/docs/development/current-work.md index 41b3187e4..f0eaf92bc 100644 --- a/docs/development/current-work.md +++ b/docs/development/current-work.md @@ -1,382 +1,69 @@ -# Current work: PR 40 CI repair +# Current work: PR 40 fork validation -Read [instruction development](instructions.md), [validation](validation.md) -and [report provenance](discovery-verification.md). Repair CI before starting -another module-index inventory scan. +Prioritize fork CI and compilation of every already-scanned assembly candidate +before resuming module-index history scanning. Read [validation](validation.md) +and [discovery verification](discovery-verification.md) before changes. -## Contribution and worktrees +## Authority and evidence -- PR 40 remains OPEN/DRAFT. Stack repair `b05c9ee` was pushed after its - 678-second full suite passed. Its CI exposed 10 standard-library job - failures (unexpanded ARM64 displacement macros and runtime-sized syscall - frames) plus one Windows test job with four fixture failures. All those - logs were inspected; the repairs were pushed as `0d96d26`. Its run - `36307233650` has passed all 10 standard-library lanes, the Linux/Windows root - matrix, race, cross-runtime, benchmark, coverage and Codecov patch checks. - At 09:57 UTC on 2026-09-27, 63 jobs passed, one failed, eight external - shards were running and 20 were queued. All 11 preceding failed jobs are - repaired. The new shard-3 failure is fixed by `a7e98cc` below; the 32-shard - replay and aggregate are still incomplete. - Inspect live results; this is not a complete green CI run. - Earlier ledger, FFR-oracle and Go-download-bootstrap repairs have passed - the affected build, cross-runtime and old-Go root CI checks. Inspect live - checks rather than treating partial results as green CI. -- Push only to `cpunion:codex/expand-ecosystem-corpus-20260913`. Never push to - `origin` or `xgo-dev`. The latest request is to push verified CI repairs - promptly, inspect the new checks and continue fixing; keep the PR draft. -- Develop on `codex/pr40-arm64-raw-20260926`. Upstream main `7cc8c0f` is - already an ancestor. Inspect status, worktrees and processes first. -- `codex/pr40-shard25-replay-20260926` retains frozen `8134b3e` diagnostics. - All its runners have finished; shard 29's final report was audited. -- A separate persistent verification worktree is frozen at `1c42a95`. - Root `go test ./... -count=1 -timeout=20m` passed in 1,040 seconds; both - nested CLI suites passed. Official five-architecture classification and - strict benchmark passed: 184/184 files, no N/A, 25 target-seconds plus six - seconds for driver build. Shards 4, 6, 10, 16 and 26 passed; all its runners - finished. Preserve the reports. They predate the affine proof work below. -- `codex/pr40-evidence-20260927` separately records validated progress for - that snapshot: 649 passed, 152 source N/A, two invalid-source skips, zero - failures and 3,980 pending. The updater also reads back its own output. - Evidence commit `16c4685`: five complete shards, 13,882 translations and - 43 target N/A; no partial shards. The complete/verified flags remain false. - This is incomplete historical evidence, not current-development success; - do not import it into a changed source snapshot as current proof. -- `codex/pr40-ci-evidence-0d96d26` preserves current CI artifacts separately: - shards 2/6/7 pass, shard 3 has the single-character stack-slot failure. - Evidence commit `cc69e2c` records 495 passed, 96 source N/A, one failed and - 4,191 pending, with 8,682 successful translations. All four reports were - audited by the updater and read back; complete/verified remain false. - Do not import these reports into the changed development snapshot. -- The affine verification worktree completed the full root suite at - `198307f` in 835 seconds, `487fed6` in 753 seconds, `8eab02c` in - 824 seconds and `a740c74` in 822 seconds. Both nested CLI suites passed - at `a740c74`; those runners finished. Its strict benchmark passed - 184/184 files, zero N/A, 60 target-seconds plus four seconds build. - Official five-architecture classification also passed again at - `487fed6`. Check the actual frozen revision and running processes before - changing that worktree. -- Use Go 1.27.1 and LLVM 22 for external modules. Put the actual Go binary in - PATH; setting only GOTOOLCHAIN can select a different child compiler. - Required cross execution uses checksum-pinned QEMU 10.2.3. +- Upstream xgo-dev PR 40 and fork cpunion PR 3 remain Draft. Push repairs only + to the fork PR 3 head, `codex/pr40-fork-ci-20260927`. Do not update its + upstream-connected base until current-head CI and exclusion review pass. +- Fork run [36509240233](https://github.com/cpunion/plan9asm/actions/runs/36509240233) + checks source `5a0699c0`. Let it finish; audit all 64 reports together. + Shards 38 and 42 failed while removing Hysteria's private Git cache, with + `directory not empty`. This is a cleanup failure, not a missing instruction. +- Older run 36458192352 attempt 2 passed all 126 jobs at source `5a894a20`: + 3,944 passed candidates, 826 source N/A and 13 explicit exceptions, with + no failed or pending candidates. That evidence cannot certify new source. +- The committed inventory remains 12,665,430 index entries, 811,704 unique + module paths, 802,019 scanned exact versions, 4,783 assembly candidates and + 13,631 retained scan failures. History starts in March 2026, not 2019. + Keep funnel tables in the PR body and preserve input records during tests. -## Latest committed repairs +## Current repairs -- `997aaf7`: classify unresolved ARM64 displacement macros as requiring - header context, while full translation rejects unexpanded offsets instead - of silently using zero. Preserve concrete pseudo-SP annotations and SVE - VL units. All seven integer load/store spellings, both directions and - hardware-SP/GP bases have Go-oracle and three-OS LLVM 22 tests. -- `75eba97`: one proved private runtime-sized stack extension uses real - dynamic storage, copies the original frame and relocates all live SP - aliases together. This preserves old data, saved SPs and address - differences. A straight-line incoming prefix must not expose the old - address; calls, escapes, numeric observations, unknown effects, multiple - extensions and re-entry reject. Size arithmetic is overflow-checked; - positive and negative register displacements preserve the original frame. - This is not a general arbitrary-pointer memory-safety proof. - Full root suite passed in 665 seconds; both nested CLIs, vet, Go 1.20 - focused tests, Darwin execution, required Linux/QEMU, three-OS objects, - official five-architecture coverage and benchmark passed. Benchmark: - 184/184 files, zero N/A, 28 target-seconds plus one second build. - Darwin/Windows ARM64 standard-library corpora passed on Go 1.27 (49/46 - IR files) and Go 1.20 (43/40), using the prebuilt current-Go CLI for the - old compiler lane. Logs use `_out/arm64-dynamic-*` and - `_out/root-tests-dynamic-stack.log` in the CI-hotfix worktree. -- Windows fixture repair `ebf6579` adds the executable suffix, tests both - LF/CRLF workflow content, and builds a native fake Go executable whose - selection and auto/local behavior are asserted. Focused Go 1.20/1.27 and - the full corpus-tool suite pass locally. Actual Windows CI now passes: - root 1,566.780 seconds with 89.6% statement coverage, and both CLI suites - passed. The previous Windows failures were fixture gaps. -- The next verified push batch includes `735fa37`, which rejects effective-address aliases such as - `$8(RSP)` before moving a dynamic stack. The prefix must account for every - live address, not just explicit register copies. Red/green and runtime - regressions passed; no escape exception was introduced. -- Local scalable pool batch: `79e31ac` adds bounded ADDVL/ADDPL aliases; - `fe70f20` partitions proofs over every architectural VL (16..256 bytes in - 16-byte increments). RDVL, ADDVL/ADDPL and scalar CNT/INC/DEC use the typed - decoders and exact pattern/multiplier values within each partition. Unknown - effects or vector-length mode changes reject. Tests cover all address - immediates and 6,144 count forms at every VL; 96 runtime cases execute at - all 16 lengths. Three-OS LLVM 22 objects, Go 1.20, vet, required Linux/QEMU, - official coverage, both CLI suites and the full root suite passed at - `fe70f20` (656 seconds). Benchmark: 184/184, zero N/A, 25 target-seconds. -- `38305da`, `b0f4b05`, `bc67343` share typed unpack, multiply-accumulate and - integer-reduction effects with the pool proof. Each family has retained - red/green evidence and complete-format checks. This changes effect - classification, not instruction semantics or unknown-op acceptance. -- `6104e5f` bounds whole Z/P pool loads with their actual VL or VL/8 footprint. - All 512 signed displacements and 16 vector lengths are checked. Three-OS - objects and 128 Linux/QEMU byte-and-canary cases pass. Stores, short pools, - negative/oversized footprints and mode changes reject. Focused pool tests, - Go 1.20 and vet pass. The development worktree completed another full suite - at this revision in 616 seconds (`_out/root-tests-sve-pool-memory.log`), - plus the official five-architecture and ARM64 Go/x/arch gates. Its runners - have finished. These changes are included in the next batch push. Refresh - derived provenance after the final source checkpoint and before pushing. -- `a7e98cc` fixes shared parsing of Go-valid single-character named stack - displacements such as `n-8(SP)`. CI shard 3 exposed the failure in - `gopkg.in/agiledragon/gomonkey.v2@v2.14.3`; both assembly files now compile - in an exact-candidate diagnostic. Identifier/offset red/green tests, - Go 1.20/1.27, three-OS objects, Darwin and required Linux/QEMU runtime pass. - Its frozen full root suite passed in 631 seconds and both CLI suites passed - (`_out/root-tests-stack-single-name.log` in the CI-hotfix worktree). -- `6b3a20ca` keeps scalar NZCV provenance through typed SVE unpack, - multiply-accumulate, integer reduction and vector/predicate logical families. - Predicate S variants, SVE comparisons, PTEST, unknown/system instructions - and bypassed comparisons remain barriers. LLVM 22 encoder tables and - red/green tests cover 131 preserving forms plus flag-writing siblings; - 131 forms x nine inputs x 16 vector lengths pass required Linux/QEMU checks. - Three-OS objects, Go 1.20, complete operand-field tests, vet and all pool - regression tests pass. The pre-fix overlay fails the new integration fixture. -- Metadata-download bootstrap and its synthetic retry fixture use the root - Go 1.20 floor; actual corpus build plans still require Go 1.27. The old - bootstrap required Go 1.27 before making even one proxy request, failing - older compatibility jobs. Go 1.20, 1.25 and 1.27 corpus tool suites pass. -- The SVE memory-offset runtime oracle now compares FFR as well as values. - Pinned QEMU deliberately stops non-faulting loads at the second page, even - for mapped memory. A deterministic cross-page fixture reproduced CI's - VL=256 failure with identical native/translated bytes and a wrong scalar - expectation. Both aligned/full-load and cross-page/valid-prefix layouts - now run; all seven non-faulting mnemonics and ordinary loads/stores pass. - No instruction, vector length or page-crossing case is skipped. -- The ARM64 stack-allocation repair includes named/raw manual SP adjustments, - pre/post-indexed transfers, saved-SP aliases, alignment and ADDVL/ADDPL at - every architectural VL. Static-offset overflow fails before allocation. - Widen changing GP aliases to unknown so pointer walks converge, but never - silently treat an unknown restored local SP as external storage. Require - bounds when SP is used again; an unused final restore before RET needs no - further allocation. This is allocation sizing, not a general memory-safety - proof for arbitrary pointer dereferences. Its real NEON parser crash is - repaired: 130 parsing and 65 formatting runtime cases pass, and the full - NEON bytes file compiles on all three OS targets. Focused Go 1.27/1.20, - Darwin runtime, Linux/QEMU and vet pass. Strict benchmark: 184/184 files, - zero N/A, 29 target-seconds. Rebuild frozen full-suite and corpus evidence; - these diagnostics are not complete external-module or shard passes. -- Its first full gate exposed 67 official SVE form regressions plus raw - structure-form writeback failures. Fixed allocation sizing for scalable - displacements/predicate units and up to four maximum-width vectors. A small - whole-function proof recognizes untouched zero-initialized GP registers - used in post-indexing; calls and writes invalidate it. Dynamic address - indexes are not frame-size declarations. All affected family tests and the - five-architecture official gate pass again without changing the baseline: - ARM64 1,980 supported forms, 68 context forms, zero unsupported forms. - The frozen full suite passed before pushing `b05c9ee`; the subsequent - standard-library integration failures are handled by the repairs above. -- `cc307d5`: reject Go command/compiler/assembler version mismatches as - infrastructure failures, never source N/A. The shard script pins its child - Go binary to the recorded GOROOT and checks all three versions. -- `ba157c3`: pin discovery and aggregate CI jobs to Go 1.27.1. -- `f151495`: track every bounded ARM64 constant-pool pointer alias separately. - MOV and 64-bit ADD/SUB immediate, shifted and extended-register forms - propagate inclusive offset ranges. Killing an original does not kill its - copies. Loads must fit at both range endpoints. Escapes, truncation, - address-dependent flags, unknown indexes and changing-offset joins fail. -- `0f08122`: retry transient module-download failures as well as dependency - build failures. At most three attempts share the original deadline. A - synthetic local proxy establishes red/green recovery, persistent failure, - no retry on 404, cancellation and workspace cleanup. Checksum, compiler, - resource and toolchain failures are not retried. -- `596b147`: follow actual CFG edges when proving integer index bounds. - An adjacent CMP must dominate B.cond; CBZ/CBNZ zero edges are also modeled. - A W comparison does not bound an X register. Bypassed guards, changed flags, - signed-negative possibilities and converging unconstrained edges fail. -- `c2567c6`: fold proven private x86 threaded TEXT helpers into one CFG. Keep - GP/vector/flag/FP state across direct and indirect jumps. CLI proof rejects - Go calls/address uses, linkname/other-file references, incomplete or escaping - tables, and paths bypassing table-base initialization. Initializers and - continuations stay in one LLVM module; wasm cannot use this mechanism. - Also honor GLOBL RODATA instead of making every global constant, and retain - file-local linkage for `<>` data. The former caused an actual runtime crash. -- `43a576e`: fix the whole VPBROADCASTB/W/D/Q family's overlapping X/Y/Z views - and inactive masked memory reads. Its 108 source/width/mask cases compile - on three OS targets and execute on Darwin/Rosetta and required Linux tests. -- `c9122cf` through `e9f131a`: add bounded modular-affine pool analysis, - guarded CMN ranges, exact AND results, transient address cancellation, - independent origin offsets, TBZ/TBNZ path constraints and pre-projection - intersections. Preserve exactly one relocation origin: two address aliases - added together must be rejected. Unknown effects/cycles and exhausted proof - budgets remain failures. See `arm64_raw_pool_affine*.go` and - `arm64_raw_pool_symbolic.go`; do not treat an ADR as an ordinary constant. -- `9acb690`: retain CMP/CMN/ADDS/SUBS provenance through known NZCV-preserving - instructions; avoid speculative residual recursion on almost-full ranges. -- `487fed6`: prove single-entry counter loops, eliminate only proved-impossible - edges, and validate the full scalar/pair pre/post-indexed load footprints. - Positive unit countdowns retain an induction bound; a proved one-iteration - loop does not invent further pointer updates. Seed proofs make a prior body - execution through an enclosing loop opaque. Invariant-origin queries use - separate cached proofs and reject changing recurrences. Predecessor order is - deterministic. Overlap, overrun, wrong-step, zero-entry and side-entry tests - fail closed. See `arm64_raw_pool_{loop,invariant,memory}.go`. -- `4ed0872`: trace flags across long integer-multiply and SIMD schedules, with - an explicit preserving-family whitelist. A TST/unknown effect still stops - the proof. The runtime oracle includes 48 instructions between SUBS and B.NE. -- `8eab02c`: multiple ADRs may reacquire the same pool at a branch join. Affine - expressions retain a relocation count and reject a sum of two origins, - including distinct ADRs. Resolve stable origins exposed after transient-index - cancellation without collecting unrelated loop predicates. Join/overrun - regressions and three-OS/native/Linux-QEMU runtime oracles passed. -- `3ed6437`, `c043b91`, `b41c426`: avoid cyclic residual speculation, retry - inconclusive caches with a fresh proof budget, and try direct guards before - residual decomposition. Compact tautologies/duplicate predicates and retain - already-proved mask constants for later comparisons. The real unsigned - parser's length proof fell from exhausting 16,384 steps to 334 steps. -- `c0652bb`: retain nonconstant AND intervals and prove disjoint-bit ORR/EOR - relationships across immediate/register/all shifted-register forms. Numeric - bit proofs must not interpret a relocated pool offset as physical address - bits. Overlap, truncation and unrelated-mask regressions remain conservative. -- `22ffd2d`: certify unit-step ordered loops, both directions, all eight - signed/unsigned strict/inclusive conditions and reversed operands. Prove - entry ordering, invariant limits, flag provenance and no arithmetic wrap. -- `4fe7c89`: comparisons constrain values at their defining program point, - including operands reused before the branch. Pending historical predicates - cannot constrain replacement values. Intersect mask/guard bounds before - applying negative displacements. Bypasses and newer flags still reject. -- `99fbba8`: infer carried address/count relationships from certified loop - deltas and prove the whole invariant at external entries. Only this entry - proof enables full affine arithmetic in the unguarded invariant walker; - speculative residuals keep the cheap mode. Increasing/decreasing post-index - runtime loops preserve the relationship instead of multiplying independent - ranges. The focused Go 1.27/1.20, three-OS LLVM 22, Darwin-native, - required Linux/QEMU and vet gates passed for every batch above. -- `a9000c0`: rewind ordinary 64-bit scalar/pair stack reloads to the exact - dominating save only when every other queried GP value is preserved. - Overlaps, possible aliasing stores, calls, SP changes, width mismatches, - joins and unknown effects reject; direct nonoverlapping SP stores are safe. - Rewinding uses the saved source value, never its later replacement. - An enclosing-loop entry barrier may preserve only registers that the - certified body never writes. Focused Go 1.27/1.20, three-OS LLVM 22, - Darwin-native, required Linux/QEMU and vet tests passed. -- `5bbda9b`: correct Go's Rm, Ra, Rn, Rd operand order across all eight - integer multiply-accumulate operations. One typed spec replaces duplicated - ordinary/word branches. Named/raw forms, all source/destination aliases and - zero operands cover 768 results against both native Go and LLVM, including - required Linux/QEMU. The prior long-multiply fixture also had the wrong - source order; the corrected fixture demonstrated the old lowering failure. -- `7f5f838`: prove exact reciprocal division with full 128-bit arithmetic, - UDIV/MSUB remainders, MOVK constant construction and LSR guard preimages. - Historical value identities reject changed/truncated operands; a guard on - an in-place quotient cannot constrain its old numerator. Focused Go 1.27 - and Go 1.20 suites, three-OS LLVM 22 objects, Darwin-native, Linux/QEMU, - supported-op extraction and vet passed. Full NEON bytes assembly compiled - on all three OS targets in diagnostics; rebuild for final corpus evidence. +- Package checks are batched per target with precise per-package fallback; + independent LLVM translator processes are bounded to two. All candidates, + files and target outcomes remain accounted for across 64 shards. +- Cache-writer regressions reproduce descendants surviving success, failure + and cancellation, including inherited output-pipe hangs. Captured Unix + commands now own and terminate their process groups; Git automatic + maintenance stays foreground. Bounded directory-not-empty retries still + report persistent cleanup failures. Other filesystem errors are not retried. +- Priority shards are 5, 29, 36, 38 and 42. All must pass before the remaining + jobs run; all 64 shards and their strict aggregate remain mandatory. +- Go 1.27.1 and LLVM 22 are pinned for external corpus evidence. Build and + test with Go while llgo support is incomplete; do not add `!llgo` tags. -The pointer/guard batches passed all focused pool tests on Go 1.27.1, -focused Go 1.20 compatibility, LLVM 22 ARM64 objects for Darwin/Linux/Windows, -Darwin native runtime and the required Linux/QEMU runtime counterparts. -The existing cross container uses Go 1.27.0 for these root runtime tests. -Both nested CLI test suites and vet passed again after the guarded-index -commit. Corpus unit tests and focused race tests passed for download retry. -Logs in the development worktree use `raw-pool-alias*`, -`raw-pool-guard*` and `discovery-download-retry*`. -The continuation batch additionally passed amd64 native-Go/LLVM runtime -oracles, required Linux amd64 and 386/QEMU execution, five x86 object targets, -Go 1.20 compatibility, both CLI suites and vet. Canonical KnoxDB's four -previously failing files passed all 12 three-OS object compilations. A separate -Linux scalar oracle passed 40 scenarios per Uint8/16/32/64 decoder (160 total), -covering all 16 selectors and mixed helper transitions. Diagnostic artifacts -are under `_out/knox-continuation-*`; these are not final shard/ledger evidence. -The affine batch passed all focused pool tests, Go 1.20 compatibility, vet, -three-OS LLVM 22 objects and Darwin/Linux-QEMU runtime oracles (80 input pairs). -TBZ/TBNZ guard tests cover all 64 bits and both edge directions. At `e9f131a`, -official classification, the ARM64 decoder-corpus gate and the strict benchmark -passed again: 184/184 files, zero N/A, 31 target-seconds plus two seconds build. -These classification and compilation gates do not establish every instruction's -runtime semantics. Keep the final full-suite and external-corpus gates separate. -The loop batches passed focused Go 1.27/1.20 tests, three-OS LLVM 22 objects, -Darwin native and required Linux/QEMU oracles. At `4ed0872`, strict benchmark -passed 184/184 files, zero N/A, 30 target-seconds plus seven seconds build. +## Provisional native-layout proposal -## External evidence and remaining real failures +GopherJRE, GoJIT and Sharkie use native object byte layouts or private JIT +continuations that ordinary semantic LLVM translation does not preserve. +Four exact-version exceptions in `testdata/corpus/native-layout.json` are +provisional, with source hashes and Go object witnesses. Other files still +compile; exceptions count separately from passes. Keep both PRs Draft until +review accepts this policy or a compatible implementation replaces it. -A passing fixture or one target replay is not a passing module or shard. -The latest three-OS SIMD replay (`_out/simd-division.json`) compiled the full -NEON bytes file, including both integer parsers and formatting. The SVE bytes -file still fails at `parseIntsSVE2`. These dirty-build diagnostics are not -passing module evidence. Do not upgrade its assembly ledger. +## Next actions -- **SIMD**: `github.com/sebishogun/simd@v1.21.1` and its mirror have 45/47 - applicable ARM64 files passing in earlier three-OS diagnostics. NEON bytes - now compiles; SVE bytes needs further proof work. Signed NEON proves both - vector loops run once and the tail counter stays in 1..7, including pair - writeback. Unsigned NEON's two vector loops are also proved one-iteration, - and the ascending signed-comparison tail now has a certified remaining - interval of 1..19. The saved SP+8 index (instruction 181 to 323) now proves - the carried pointer/count invariant is exactly 144 bytes relative to the - pool. Both integer parsers translate. Formatting's instruction 59 two-digit - table read is now bounded by a general UMULH/shift reciprocal-division and - MSUB-remainder proof, plus historical right-shift guard bounds. The proof - is not special-cased to divisor 100. The expanded runtime oracle exposed - and now guards the independent multiply-accumulate operand-order bug. - Inspect `_out/format-ints-neon-disasm.log` and `pool-format-neon.log`. - Repeated ADRs retain relocation cardinality. The ignored function probe's - scratch mask now has explicit parentheses; its earlier missing parentheses - caused a diagnostic-only false return-escape failure. - Signed SVE's instruction 23 now has all-VL address semantics and all its GP - effects are recognized. Typed SVE flag preservation is now implemented. - It still needs indexed predicated pool-load footprints and relational - loop bounds. In the current diagnostic, instruction 76's `R12+8*R20` - footprint is still unknown at every checked VL; do not accept it merely - because the typed load is supported. Ignored `pool-function-*` probes and - disassemblies retain diagnostics; regenerate any traced walker after edits. - The apparent branch - word `0x540be400` is numeric pool data, not invalid source. Implement in - `arm64_raw_pool_*.go`; do not relax the proof merely to relocate the pool. -- **KnoxDB**: all three complete v0.2.9 candidates (`blockwatch.cc/knoxdb`, - `github.com/blockwatch-cc/knoxdb`, `github.com/os2357/knx`) passed at `1c42a95`: - each has 29 applicable files and 87 object translations. Shards 6/16/26 - passed. Empty Go declarations do not imply callable helpers: private - entry proof is essential. Never apply signature widening to ordinary calls. -- **Native-layout/JIT**: GopherJRE, GoJIT and both case-distinct Sharkie module - paths still fail. Their source observes exact code offsets or transfers - registers/stack through generated machine code. Widening raw branch - boundaries alone is insufficient. There is no authorized generic - native-layout skip. A newer GoJIT version was downloaded diagnostically but - has not been imported or verified as a replacement. -- Exact invalid-source skips (including both go-highway versions), historical - gVisor/Skywire superseded skips and the pinned fiber/ai private AMX exception - remain distinct from passes. Their manifests and executable proof tests are - authoritative. Do not extend their scope without evidence/authorization. +1. Finish and audit the current run. Preserve its reports under ignored + `_out/`; do not mix them with reports from a repaired source. +2. Validate cleanup regressions, corpus-tool tests, race tests and workflow + partition checks. Commit the repair and rebind the pending assembly ledger + from an empty ignored reports directory before pushing to the fork. +3. Run the complete repaired-source CI. Audit all 64 reports against the + frozen source, tool bytes, scan ledger and candidate ownership; publish + actual progress through `scripts/update-assembly-ledger.sh`, including + failures or pending candidates rather than hand-upgrading statuses. +4. Update the PR funnel and performance comparison from verified reports. + Resolve review, patch coverage and the provisional exclusions before + promotion. Object compilation is not every external project's runtime test. -The frozen `8134b3e` diagnostics are stored under -`_out/ci-repair-8134-shardN/shard-N.json`. Completed shards 1, 13, 20, 22, -23, 24, 25 and 29 have zero failures and individually audited reports. Shards -0, 6, 9, 14, 15, 16, 17, 26 and 28 retain the real failure classes above -(shard 16 also predates the dev9 invalid-source fix). Shard 4 completed -168 candidates = 123 passed + 35 source N/A + one invalid-source skip + -nine download TLS-timeout failures; its integrity audit passed, not its -coverage gate. Shard 10 completed 173 candidates with 133 passed, 29 N/A and -11 proxy TLS-timeout failures; its large spanneranalyzer p0/p6 candidates -passed. Shard 29 completed 151 candidates = 127 passed + 23 source N/A + one -invalid-source skip, with 2,052 object translations and three target N/A files. +An incremental scan with an empty output ledger starts at now; `-seen-report` +only deduplicates and does not inherit its cursor. Seed a separate output with +the converter before continuing the saved interval. Do not merge an empty +now-to-now scan as if it covered the committed incremental gap. -A separate clean `cc307d5` shard 16 report has 132 passed + 30 N/A + one -go-highway invalid-source skip + one KnoxDB failure. It is not compatible -with the older frozen reports. Keep all of these as diagnostic evidence. - -## Next actions and completion gates - -1. Preserve the validated 4/6/10/16/26 evidence in its same-source tree. - Shard 4 passed 168 candidates: 130 passed, 37 N/A, one invalid-source skip, - 4,152 translations and 25 target N/A. Never relabel network failures as N/A. - Shard 10 passed 173 candidates: 142 passed, 31 N/A, 2,889 translations and - six target N/A. Both retry runs and their ledger updates are complete. -2. Continue real semantic fixes above with red/green and runtime tests. - NEON bytes now compiles; SVE bytes and native-layout/JIT failures remain. -3. Run full root, both nested CLIs, vet/build, official five-architecture - coverage, ARM64/stdlib corpus, cross runtime and strict benchmark after - the final implementation. Preserve evidence by exact source snapshot. -4. Freeze source/tools/ledger, run all 32 shards on identical provenance, - then use the validated updater to refresh the assembly ledger. Even doc - commits change provenance; do not combine old reports into the final run. -5. Batch-push the allowed fork, update PR body with validated scan/coverage - funnels, and inspect current-head CI/review/coverage before making ready. - -The current-source assembly evidence remains incomplete: all 4,783 candidates -are pending until compatible frozen shard reports exist. Earlier passes are -preserved in their own snapshots, not promoted to this source. Refresh the -derived ledger with its validated updater before pushing; never hand-edit it -to match diagnostic counts. Reports/binaries belong in ignored -`_out/`; clean only owned generated files and candidate caches, never shared -module caches or unrelated containers. Old progress narratives remain in Git -history instead of accumulating in this checkpoint. +Clean only owned temporary files. Never commit caches, binaries, compressed +records, personal paths or diagnostic-only reports. diff --git a/docs/development/discovery-verification.md b/docs/development/discovery-verification.md index 1519f8798..7fde71088 100644 --- a/docs/development/discovery-verification.md +++ b/docs/development/discovery-verification.md @@ -100,12 +100,12 @@ Never accept arbitrary frame mismatches just to make historical modules pass. ## Reports and provenance -Corpus sharding is `sha256(module@version) % 32`, independent of module-hashed +Corpus sharding is `sha256(module@version) % 64`, independent of module-hashed ledger files. Parallel shards read one frozen ledger and write distinct reports: ```sh PLAN9ASM_DISCOVERY_PARALLELISM=4 \ - scripts/check-discovered-library-corpus.sh all 32 + scripts/check-discovered-library-corpus.sh all 64 scripts/verify-discovered-library-corpus.sh _out/discovered-library-corpus ``` @@ -121,7 +121,7 @@ runner. The aggregate checks exact ledger ownership/inventory and these identities: -- `selected = passed + not_applicable + skipped_invalid_source + skipped_superseded + skipped_private_extension + failed`; +- `selected = passed + not_applicable + skipped_invalid_source + skipped_superseded + skipped_private_extension + skipped_native_layout + failed`; - each target's `total_asm = success + not_applicable + failed`; - final `failed = 0`, each candidate appearing exactly once. @@ -159,7 +159,17 @@ files. Its successful translations are retained, but the module is counted as skipped rather than passed. A changed file, failed Go assembly or failed other translation makes the candidate fail. -Schema 6 binds Git revision/content/dirty state, full ledger fingerprint, +`skipped_native_layout` is reserved for byte-exact native TEXT/JIT behavior, +not a missing instruction form. This is a provisional Draft PR proposal, not +an accepted coverage policy. `testdata/corpus/native-layout.json` pins the +exact module version, source file, every selected target, source SHA-256, +symbol, Go object bytes and reviewable evidence. The runner reassembles the +file with current Go for each target, checks the byte witness inside that +symbol, and compiles every other applicable file. The candidate is never +counted as passed; an unpinned target, stale source, changed object bytes or +failed remaining translation fails the candidate. + +Schema 7 binds Git revision/content/dirty state, full ledger fingerprint, translator bytes/VCS metadata, matching Go build/runtime versions and LLVM 22 version/llc bytes. Before/after capture detects mutations. All shards need identical provenance. Dirty builds are diagnostic-only; schema-2, stale tools, @@ -181,11 +191,12 @@ reports yet: bash scripts/discovery-status.sh [reports-directory] [shard-count] ``` -The defaults are `_out/discovered-library-corpus` and 32. `scan-status.json` +The defaults are `_out/discovered-library-corpus` and 64. `scan-status.json` contains validated contiguous index endpoints and scan counts. `assembly-progress.json` binds the ledger/source and lists every selected exact version as `pending`, `passed`, `not_applicable`, `skipped_invalid_source`, -`skipped_superseded`, `skipped_private_extension` or `failed`. Its invariant +`skipped_superseded`, `skipped_private_extension`, `skipped_native_layout` or +`failed`. Its invariant includes every one of those categories exactly once. Missing whole shards remain pending, including in-progress shards not yet published. @@ -246,6 +257,12 @@ successful package compilation. Public module fetches ignore the user's global Git URL rewrites and disable interactive Git credential prompts. A failed direct fallback remains a failure to retry, never proof that assembly is inapplicable. +Git automatic maintenance stays in the foreground. On Unix, each captured +command owns a process group; cancellation and ordinary parent exit terminate +remaining descendants before workspace removal. Output-pipe waits are bounded +and failures remain infrastructure errors. Directory-not-empty cleanup races +receive at most 20 attempts over two seconds; persistent errors fail the +candidate rather than claiming successful cleanup. Traffic counts response-body bytes with identity encoding, not headers/TLS/IP overhead, separating index, latest, ZIP HEAD/range and whole-ZIP requests. Do diff --git a/docs/development/validation.md b/docs/development/validation.md index 9fea589fc..489913812 100644 --- a/docs/development/validation.md +++ b/docs/development/validation.md @@ -56,7 +56,7 @@ scripts/benchmark-compile.sh scripts/check-reported-library-corpus.sh all PLAN9ASM_DISCOVERY_PARALLELISM=4 \ - scripts/check-discovered-library-corpus.sh all 32 + scripts/check-discovered-library-corpus.sh all 64 ``` Run race tests for changed concurrent code and focused Go 1.20 compatibility @@ -111,7 +111,7 @@ unchanged until corpus verification finishes. A separate persistent worktree allows development to continue. Reports under `_out/` may be written without changing tracked source. -The aggregate requires all 32 schema-6 reports, exact candidate ownership and +The aggregate requires all 64 schema-7 reports, exact candidate ownership and one identical source/ledger/tool provenance. Never mix revisions, dirty builds, tool binaries or partial CI artifact sets. Even documentation changes alter the source fingerprint: old reports prove only their exact revision, not current- @@ -140,3 +140,67 @@ head success. See [discovery verification](discovery-verification.md). - Keep obsolete compressed/run records out of contribution history as well as the final tree. A necessary rewrite uses `--force-with-lease` only on the allowed fork after checking for remote changes. + +## Fork-first CI + +PR 40 remains open upstream, but repair iterations run in `cpunion/plan9asm`: + +1. Let the already active upstream CI run finish once. Do not cancel it or + update its head while it is running. +2. Create a distinct repair branch from the integrated fixes. Open a Draft PR + **in the fork**, targeting `codex/expand-ecosystem-corpus-20260913`, the fork + branch already used by upstream PR 40. Do not target fork `main` or update + the upstream-connected branch during repair iterations. +3. Inspect the workflow before pushing. Its owner-based runner selection must + use GitHub-hosted runners in the fork, not upstream qiniu runners. Preserve + all test and coverage gates. A Draft PR still needs to execute CI. +4. Batch fixes on the new branch; inspect completed job logs and publish only + reports matching the frozen source and scan ledger. An old upstream pass + does not establish a pass for the repair branch. +5. Only after the fork PR's current-head CI passes and review accepts any + proposed coverage exclusions, refresh both remote refs + and integrate the validated commits into the existing PR 40 head in the + fork. Prefer a fast-forward; if either branch changed, verify the resulting + tree before promotion. This is a branch update, not a merge of upstream + PR 40. Upstream CI is then expected to run once on the promoted batch. + +Use explicit `--repo cpunion/plan9asm`, `--base`, and `--head` arguments when +creating the staging PR. Its body should link PR 40 and distinguish the repair +diff, original-run evidence and current-head validation. Keep both PRs draft +until their respective completion gates pass; do not close upstream PR 40. + +A clearly disclosed, provisional exception mechanism may be evaluated in the +authorized fork Draft PR. That does not accept the exception policy: count +exclusions separately, keep Draft, and resolve their review before promotion. + +### Failure-first scheduling + +Let the active full run finish before publishing another batch. A cheap +`ci_policy` job first checks workflow scheduling and root CI policy tests. +The repair matrix then runs the five previously failing discovery shards. +Every other entry job has a native `needs` dependency on that matrix: **all +priority shards must succeed**, not merely start, before the remaining jobs +run. Fail-fast is disabled so every failed shard is checked. +There is no polling job consuming a runner while waiting. + +The strict aggregate still runs after a failed priority matrix and reports the +missing/failed coverage; skipped downstream work cannot make the run green. +All 64 shards remain required, with identical compilation and artifact steps. +No old-source artifact can replace a current-head rerun. + +When changing the priority set, update both workflow matrices and +the scheduling regression tests together. Validate their exhaustive, +non-overlapping partition, identical test steps and success dependencies: + +```sh +node --test .github/scripts/priority-*.test.cjs +actionlint .github/workflows/go-ci.yml +``` + +If the user requests cancellation after pushing, cancel the new automatic run, +wait for its terminal status, then rerun the workflow. The dependency graph +starts with `ci_policy`, then the priority matrix, and releases the other jobs +after both pass. +GitHub rejects another single-job rerun while a workflow attempt is running; +repeated job-rerun API calls cannot enqueue a concurrent priority group. Keep +all scheduling operations in the allowed fork, never in upstream Actions. diff --git a/parser.go b/parser.go index a17ea8f62..765220686 100644 --- a/parser.go +++ b/parser.go @@ -400,24 +400,34 @@ func parseGLOBLStmt(rest string) (GloblStmt, error) { } func splitSymPlusOff(s string) (sym string, off int64) { - // Best-effort parse for forms like: - // name+0 - // name-8 - // If offset parsing fails, treat the entire string as a symbol name. + // Go's assembler parses the displacement as a constant expression after + // the symbol token. Whitespace around the operator and expressions such + // as name+(2*4) are therefore valid, not part of the symbol name. + // If no suffix parses as an expression, retain the entire symbol. s = strings.TrimSpace(s) if s == "" { return "", 0 } - // Prefer the last '+' or '-' as the separator. - sep := strings.LastIndexAny(s, "+-") - if sep <= 0 || sep == len(s)-1 { - return s, 0 - } - n, err := parseInt(s[sep:]) - if err != nil { - return s, 0 + for sep := 1; sep < len(s); sep++ { + if s[sep] != '+' && s[sep] != '-' { + continue + } + name := strings.TrimSpace(s[:sep]) + if name == "" { + continue + } + if n, ok := parseImmExpr(s[sep:]); ok { + return name, int64(n) + } } - return strings.TrimSpace(s[:sep]), n + return s, 0 +} + +// SplitSymbolOffset parses a Go assembly symbol and its constant-expression +// displacement. An unparseable displacement leaves the input unchanged so +// callers can report the original symbol in their own context. +func SplitSymbolOffset(s string) (sym string, off int64) { + return splitSymPlusOff(s) } func parseInt(s string) (int64, error) { diff --git a/parser_symbol_offset_spacing_test.go b/parser_symbol_offset_spacing_test.go new file mode 100644 index 000000000..45ceae15a --- /dev/null +++ b/parser_symbol_offset_spacing_test.go @@ -0,0 +1,58 @@ +package plan9asm + +import "testing" + +func TestParseSymbolOffsetsWithGoExpressionWhitespace(t *testing.T) { + tests := []struct { + text string + name string + off int64 + kind OperandKind + }{ + {"z+ 0(FP)", "z", 0, OpFP}, + {"x + 8(FP)", "x", 8, OpFP}, + {"y - 16(FP)", "y", -16, OpFP}, + {"result+(4*2)(FP)", "result", 8, OpFP}, + {"result+8-4(FP)", "result", 4, OpFP}, + {"$result + 8(FP)", "result", 8, OpFPAddr}, + } + for _, tt := range tests { + t.Run(tt.text, func(t *testing.T) { + got, err := parseOperand(tt.text) + if err != nil { + t.Fatal(err) + } + if got.Kind != tt.kind || got.FPName != tt.name || got.FPOffset != tt.off { + t.Fatalf("parseOperand(%q) = %+v, want %v %q %+d", tt.text, got, tt.kind, tt.name, tt.off) + } + }) + } +} + +func TestSplitSymbolOffsetsWithGoExpressionWhitespace(t *testing.T) { + tests := []struct { + text string + name string + off int64 + }{ + {"foo+ 8", "foo", 8}, + {"foo - 8", "foo", -8}, + {"foo+(2*4)", "foo", 8}, + {"foo+8-4", "foo", 4}, + {"foo-bar", "foo-bar", 0}, + } + for _, tt := range tests { + gotName, gotOff := splitSymPlusOff(tt.text) + if gotName != tt.name || gotOff != tt.off { + t.Errorf("splitSymPlusOff(%q) = %q, %d; want %q, %d", tt.text, gotName, gotOff, tt.name, tt.off) + } + } +} + +func TestParseFPRejectsUnresolvedOffsets(t *testing.T) { + for _, source := range []string{"arg+unknown(FP)", "arg-unknown(FP)"} { + if _, _, ok := parseFP(source); ok { + t.Errorf("parseFP(%q) accepted an unresolved offset", source) + } + } +} diff --git a/scripts/check-discovered-library-corpus.sh b/scripts/check-discovered-library-corpus.sh index 65d4048dd..f5ec7a65a 100755 --- a/scripts/check-discovered-library-corpus.sh +++ b/scripts/check-discovered-library-corpus.sh @@ -10,7 +10,7 @@ if [[ "$shard_index" == "all" ]]; then echo "usage: $0 all [shard-count]" >&2 exit 2 fi - shard_count=${2:-32} + shard_count=${2:-64} parallelism=${PLAN9ASM_DISCOVERY_PARALLELISM:-4} if ! [[ "$shard_count" =~ ^[1-9][0-9]*$ && "$parallelism" =~ ^[1-9][0-9]*$ ]]; then echo "invalid shard count or PLAN9ASM_DISCOVERY_PARALLELISM" >&2 diff --git a/scripts/discovery-status.sh b/scripts/discovery-status.sh index daa06ab9c..eb77cdd00 100644 --- a/scripts/discovery-status.sh +++ b/scripts/discovery-status.sh @@ -3,7 +3,7 @@ set -euo pipefail repo_root=$(cd "$(dirname "$0")/.." && pwd) reports_dir=${1:-"$repo_root/_out/discovered-library-corpus"} -shard_count=${2:-32} +shard_count=${2:-64} if (( $# > 2 )) || ! [[ "$shard_count" =~ ^[1-9][0-9]*$ ]]; then echo "usage: $0 [reports-directory] [shard-count]" >&2 exit 2 diff --git a/scripts/update-assembly-ledger.sh b/scripts/update-assembly-ledger.sh index 0065de63d..af477ef0e 100755 --- a/scripts/update-assembly-ledger.sh +++ b/scripts/update-assembly-ledger.sh @@ -3,7 +3,7 @@ set -euo pipefail repo_root=$(cd "$(dirname "$0")/.." && pwd) reports_dir=${1:-"$repo_root/_out/discovered-library-corpus"} -shard_count=${2:-32} +shard_count=${2:-64} output_dir="$repo_root/testdata/discovery/assembly-ledger" if (( $# > 2 )) || ! [[ "$shard_count" =~ ^[1-9][0-9]*$ ]]; then diff --git a/testdata/corpus/embedded-modules.json b/testdata/corpus/embedded-modules.json new file mode 100644 index 000000000..e9a1225d0 --- /dev/null +++ b/testdata/corpus/embedded-modules.json @@ -0,0 +1,21 @@ +{ + "schema_version": 1, + "aliases": [ + { + "module": "github.com/EthanGYoung/ContainerFS", + "version": "v0.0.0-20190824181837-c8de36334a9e", + "module_sum": "h1:UuU7qgyQs6POuZjS0dAJAG5R/aCIa8bVQRWAHuGzKWw=", + "source_dir": "ContainerFS", + "import_module": "gvisor.googlesource.com/gvisor", + "evidence_url": "https://github.com/EthanGYoung/ContainerFS/tree/c8de36334a9e/ContainerFS" + }, + { + "module": "github.com/ethangyoung/containerfs", + "version": "v0.0.0-20190824181837-c8de36334a9e", + "module_sum": "h1:aQHHJ3hoxLd1CG2TcaRHc1DLHaXWOvnKYqkIABK/+ZU=", + "source_dir": "ContainerFS", + "import_module": "gvisor.googlesource.com/gvisor", + "evidence_url": "https://github.com/EthanGYoung/ContainerFS/tree/c8de36334a9e/ContainerFS" + } + ] +} diff --git a/testdata/corpus/native-layout.json b/testdata/corpus/native-layout.json new file mode 100644 index 000000000..e41f62198 --- /dev/null +++ b/testdata/corpus/native-layout.json @@ -0,0 +1,57 @@ +{ + "schema_version": 1, + "skips": [ + { + "module": "github.com/quasilyte/GopherJRE", + "version": "v0.0.0-20250131090304-888098460476", + "asm_file": "jruntime/call_amd64.s", + "targets": ["darwin/amd64", "linux/amd64", "windows/amd64"], + "source_sha256": "def9bd4b4d1faccabcd3b30722e7400b6f7bd0db415495c5ca4e9279dbc1afc0", + "symbol": "jcallScalar", + "object_hex": "488d0509000000488906", + "reason": "The source saves a return address calculated inside its own TEXT and jumps into foreign code; the Go object encodes a byte-exact RIP-relative LEA. This exact native-layout dependency is not yet modeled by LLVM translation.", + "evidence_urls": [ + "https://github.com/quasilyte/GopherJRE/blob/888098460476/jruntime/call_amd64.s" + ] + }, + { + "module": "github.com/aabalke/gojit", + "version": "v0.0.0-20260919011104-e8574bd413ce", + "asm_file": "jit_amd64.s", + "targets": ["darwin/amd64", "linux/amd64", "windows/amd64"], + "source_sha256": "8e9ac14a0cb2d64c5cc228c68fcab7f28c26c702a207cb1b613680caa63a4541", + "symbol": "callJIT", + "object_hex": "00beadde4c89", + "reason": "The JIT scans this native TEXT for a literal sentinel and branches into the middle of the function. LLVM translation does not preserve this byte layout or the source's custom control-flow contract.", + "evidence_urls": [ + "https://github.com/aabalke/gojit/blob/e8574bd413ce/jit_amd64.s" + ] + }, + { + "module": "github.com/LamkasDev/sharkie", + "version": "v0.0.0-20260919122514-0af66a893005", + "asm_file": "cmd/asm/runner_amd64.s", + "targets": ["darwin/amd64", "linux/amd64", "windows/amd64"], + "source_sha256": "a365b2d8c88ded99e7de6dae28a082a16466b23bd6f52e6bfb3ee68d3737213e", + "symbol": "Run", + "object_hex": "eb05e800000000", + "reason": "The source branches over a five-byte CALL and constructs a return address at Run+7, depending on its native TEXT layout and custom stack transition. The remaining assembly files still require translation and object compilation.", + "evidence_urls": [ + "https://github.com/LamkasDev/sharkie/blob/0af66a893005/cmd/asm/runner_amd64.s" + ] + }, + { + "module": "github.com/lamkasdev/sharkie", + "version": "v0.0.0-20260919122514-0af66a893005", + "asm_file": "cmd/asm/runner_amd64.s", + "targets": ["darwin/amd64", "linux/amd64", "windows/amd64"], + "source_sha256": "a365b2d8c88ded99e7de6dae28a082a16466b23bd6f52e6bfb3ee68d3737213e", + "symbol": "Run", + "object_hex": "eb05e800000000", + "reason": "The source branches over a five-byte CALL and constructs a return address at Run+7, depending on its native TEXT layout and custom stack transition. The remaining assembly files still require translation and object compilation.", + "evidence_urls": [ + "https://github.com/lamkasdev/sharkie/blob/0af66a893005/cmd/asm/runner_amd64.s" + ] + } + ] +} diff --git a/testdata/discovery/README.md b/testdata/discovery/README.md index ed98f01cd..c1d129663 100644 --- a/testdata/discovery/README.md +++ b/testdata/discovery/README.md @@ -154,9 +154,9 @@ replayed directly from the saved exact versions without reading the module index or revisiting no-assembly modules: ```sh -for shard in $(seq 0 31); do +for shard in $(seq 0 63); do PLAN9ASM_DISCOVERY_TARGETS=linux/riscv64 \ - scripts/check-discovered-library-corpus.sh "$shard" 32 \ + scripts/check-discovered-library-corpus.sh "$shard" 64 \ "_out/discovered-library-corpus/riscv64-shard-$shard.json" done ``` diff --git a/testdata/discovery/assembly-ledger/manifest.json b/testdata/discovery/assembly-ledger/manifest.json index 5a5453cd0..caa2dfb08 100644 --- a/testdata/discovery/assembly-ledger/manifest.json +++ b/testdata/discovery/assembly-ledger/manifest.json @@ -1,12 +1,12 @@ { "format": "module-hashed-assembly-ledger-v1", - "semantic_source_sha256": "b1fd316d0a61ffbfdf67e43afcf0bd0e9bde404e296bfa61954d0e55252efd92", + "semantic_source_sha256": "4b79a8db6b2d40de0c6b5a510bacaaed67ab2181b17f72cc56fbe052016ff323", "progress": { "schema_version": 1, "validation_kind": "translation_and_llvm22_object_compilation", "source": { - "revision": "afad7d14c10970eb9c59c34c95e7374327c248a7", - "sha256": "b1fd316d0a61ffbfdf67e43afcf0bd0e9bde404e296bfa61954d0e55252efd92", + "revision": "e1fb8915342f891b56216ead58dcc295c64a81af", + "sha256": "4b79a8db6b2d40de0c6b5a510bacaaed67ab2181b17f72cc56fbe052016ff323", "dirty": false }, "ledger_sha256": "eaffb5faf292a3ef4c2b5a9ce4ebc15be4297ce78dc4d5ac2683b1bebac77372", @@ -23,7 +23,7 @@ "js/wasm", "wasip1/wasm" ], - "shard_count": 32, + "shard_count": 64, "reported_shards": 0, "passed_shards": 0, "failed_shards": 0, @@ -60,7 +60,39 @@ 28, 29, 30, - 31 + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63 ], "candidate_total": 4783, "passed": 0, @@ -69,6 +101,7 @@ "skipped_invalid_source": 0, "skipped_superseded": 0, "skipped_private_extension": 0, + "skipped_native_layout": 0, "pending": 4783, "discovered_asm_files": 61029, "translations": 0, diff --git a/types.go b/types.go index 2232381ad..7a97f7a8c 100644 --- a/types.go +++ b/types.go @@ -658,7 +658,7 @@ func parseFP(s string) (name string, off int64, ok bool) { return "", 0, false } name, off = splitSymPlusOff(base) - if name == "" || strings.IndexAny(name, " \t,()") >= 0 { + if name == "" || strings.IndexAny(name, " \t,()+-") >= 0 { return "", 0, false } return name, off, true