Skip to content

Commit 2cab7c3

Browse files
authored
File lock implementation (#13)
1 parent e8e113b commit 2cab7c3

File tree

9 files changed

+317
-22
lines changed

9 files changed

+317
-22
lines changed

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*.go text eol=lf

.github/workflows/go.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: Build and Test
2+
3+
on:
4+
pull_request:
5+
types: [opened, reopened, synchronize]
6+
push:
7+
branches: [dev, main]
8+
9+
jobs:
10+
build_test:
11+
strategy:
12+
matrix:
13+
os: [macos-latest, ubuntu-latest, windows-latest]
14+
15+
runs-on: ${{matrix.os}}
16+
17+
steps:
18+
- uses: actions/checkout@v3
19+
20+
- uses: actions/setup-go@v4
21+
with:
22+
go-version-file: "go.mod"
23+
24+
- name: Build
25+
run: go build -x ./extensions/...
26+
27+
- name: Test
28+
run: go test -race -v ./...
29+
30+
- name: Lint
31+
# lint even when a previous step failed
32+
if: ${{!cancelled()}}
33+
uses: golangci/golangci-lint-action@v3
34+
with:
35+
version: v1.52
36+
args: -v

.golangci.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
linters:
2+
enable:
3+
- gocritic
4+
- gofmt
5+
6+
linters-settings:
7+
gocritic:
8+
enabled-checks:
9+
- evalOrder

extensions/internal/flock/flock.go

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ package flock
1919
import (
2020
"context"
2121
"os"
22-
"runtime"
2322
"sync"
2423
"time"
2524
)
@@ -120,24 +119,11 @@ func tryCtx(ctx context.Context, fn func() (bool, error), retryDelay time.Durati
120119
}
121120

122121
func (f *Flock) setFh() error {
123-
// open a new os.File instance
124-
// create it if it doesn't exist, and open the file read-only.
125-
flags := os.O_CREATE
126-
if runtime.GOOS == "aix" {
127-
// AIX cannot preform write-lock (ie exclusive) on a
128-
// read-only file.
129-
flags |= os.O_RDWR
130-
} else {
131-
flags |= os.O_RDONLY
122+
fh, err := os.OpenFile(f.path, os.O_CREATE|os.O_RDWR, os.FileMode(0600))
123+
if err == nil {
124+
f.fh = fh
132125
}
133-
fh, err := os.OpenFile(f.path, flags, os.FileMode(0600))
134-
if err != nil {
135-
return err
136-
}
137-
138-
// set the filehandle on the struct
139-
f.fh = fh
140-
return nil
126+
return err
141127
}
142128

143129
// ensure the file handle is closed if no lock is held

extensions/internal/flock/flock_winapi.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,8 @@ const (
3333
// https://msdn.microsoft.com/en-us/library/windows/desktop/aa365203(v=vs.85).aspx
3434

3535
func lockFileEx(handle syscall.Handle, flags uint32, reserved uint32, numberOfBytesToLockLow uint32, numberOfBytesToLockHigh uint32, offset *syscall.Overlapped) (bool, syscall.Errno) {
36-
r1, _, errNo := syscall.Syscall6(
36+
r1, _, errNo := syscall.SyscallN(
3737
uintptr(procLockFileEx),
38-
6,
3938
uintptr(handle),
4039
uintptr(flags),
4140
uintptr(reserved),
@@ -55,9 +54,8 @@ func lockFileEx(handle syscall.Handle, flags uint32, reserved uint32, numberOfBy
5554
}
5655

5756
func unlockFileEx(handle syscall.Handle, reserved uint32, numberOfBytesToLockLow uint32, numberOfBytesToLockHigh uint32, offset *syscall.Overlapped) (bool, syscall.Errno) {
58-
r1, _, errNo := syscall.Syscall6(
57+
r1, _, errNo := syscall.SyscallN(
5958
uintptr(procUnlockFileEx),
60-
5,
6159
uintptr(handle),
6260
uintptr(reserved),
6361
uintptr(numberOfBytesToLockLow),

extensions/internal/lock/lock.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License. See LICENSE in the project root for license information.
3+
4+
package lock
5+
6+
import (
7+
"context"
8+
"errors"
9+
"fmt"
10+
"os"
11+
"path/filepath"
12+
"runtime"
13+
"syscall"
14+
"time"
15+
16+
"github.com/AzureAD/microsoft-authentication-extensions-for-go/extensions/internal/flock"
17+
)
18+
19+
// timeout lets tests set the default amount of time allowed to acquire the lock
20+
var timeout = 5 * time.Second
21+
22+
// flocker helps tests fake flock
23+
type flocker interface {
24+
Fh() *os.File
25+
Path() string
26+
TryLockContext(context.Context, time.Duration) (bool, error)
27+
Unlock() error
28+
}
29+
30+
// Lock uses a file lock to coordinate access to resources shared with other processes.
31+
// Callers are responsible for preventing races within a process. Lock applies advisory
32+
// locks on Linux and macOS and is therefore unreliable on these platforms when several
33+
// processes concurrently try to acquire the lock.
34+
type Lock struct {
35+
f flocker
36+
retryDelay time.Duration
37+
}
38+
39+
// New is the constructor for Lock. "p" is the path to the lock file.
40+
func New(p string, retryDelay time.Duration) (*Lock, error) {
41+
// ensure all dirs in the path exist before flock tries to create the file
42+
err := os.MkdirAll(filepath.Dir(p), os.ModePerm)
43+
if err != nil {
44+
return nil, err
45+
}
46+
return &Lock{f: flock.New(p), retryDelay: retryDelay}, nil
47+
}
48+
49+
// Lock acquires the file lock on behalf of the process. The behavior of concurrent
50+
// and repeated calls is undefined. For example, Linux may or may not allow goroutines
51+
// scheduled on different threads to hold the lock simultaneously.
52+
func (l *Lock) Lock(ctx context.Context) error {
53+
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
54+
var cancel context.CancelFunc
55+
ctx, cancel = context.WithTimeout(ctx, timeout)
56+
defer cancel()
57+
}
58+
for {
59+
// flock opens the file before locking it and returns errors due to an existing
60+
// lock or one acquired by another process after this process has opened the
61+
// file. We ignore some errors here because in such cases we want to retry until
62+
// the deadline.
63+
locked, err := l.f.TryLockContext(ctx, l.retryDelay)
64+
if err != nil {
65+
if !(errors.Is(err, os.ErrPermission) || isWindowsSharingViolation(err)) {
66+
return err
67+
}
68+
} else if locked {
69+
if fh := l.f.Fh(); fh != nil {
70+
s := fmt.Sprintf("{%d} {%s}", os.Getpid(), os.Args[0])
71+
_, _ = fh.WriteString(s)
72+
}
73+
return nil
74+
}
75+
}
76+
}
77+
78+
// Unlock releases the lock and deletes the lock file.
79+
func (l *Lock) Unlock() error {
80+
err := l.f.Unlock()
81+
if err == nil {
82+
err = os.Remove(l.f.Path())
83+
}
84+
// ignore errors caused by another process deleting the file or locking between the above Unlock and Remove
85+
if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) || isWindowsSharingViolation(err) {
86+
return nil
87+
}
88+
return err
89+
}
90+
91+
func isWindowsSharingViolation(err error) bool {
92+
return runtime.GOOS == "windows" && errors.Is(err, syscall.Errno(32))
93+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License. See LICENSE in the project root for license information.
3+
4+
package lock
5+
6+
import (
7+
"bytes"
8+
"context"
9+
"errors"
10+
"io"
11+
"os"
12+
"path/filepath"
13+
"runtime"
14+
"testing"
15+
"time"
16+
17+
"github.com/stretchr/testify/require"
18+
)
19+
20+
var ctx = context.Background()
21+
22+
type fakeFlock struct {
23+
err error
24+
p string
25+
}
26+
27+
func (f fakeFlock) Fh() *os.File {
28+
fh, _ := os.Open(f.p)
29+
return fh
30+
}
31+
32+
func (f fakeFlock) Path() string {
33+
return f.p
34+
}
35+
36+
func (f fakeFlock) TryLockContext(context.Context, time.Duration) (bool, error) {
37+
return f.err == nil, f.err
38+
}
39+
40+
func (f fakeFlock) Unlock() error {
41+
return f.err
42+
}
43+
44+
func TestCreatesAndRemovesFile(t *testing.T) {
45+
p := filepath.Join(t.TempDir(), "nonexistent", t.Name())
46+
lock, err := New(p, 0)
47+
require.NoError(t, err)
48+
require.NoFileExists(t, p)
49+
50+
err = lock.Lock(ctx)
51+
require.NoError(t, err)
52+
require.FileExists(t, p, "Lock didn't create the file")
53+
54+
buf := bytes.NewBuffer(nil)
55+
_, err = io.Copy(buf, lock.f.Fh())
56+
require.NoError(t, err)
57+
require.NotEmpty(t, buf, "Lock didn't write debug info to the locked file")
58+
59+
err = lock.Unlock()
60+
require.NoError(t, err)
61+
require.NoFileExists(t, p, "Unlock didn't remove the file")
62+
}
63+
64+
func TestFileExists(t *testing.T) {
65+
p := filepath.Join(t.TempDir(), t.Name())
66+
f, err := os.Create(p)
67+
require.NoError(t, err)
68+
data := "stuff"
69+
_, err = f.WriteString(data)
70+
require.NoError(t, err)
71+
require.NoError(t, f.Close())
72+
73+
// Lock should succeed when the file exists but isn't locked
74+
lock, err := New(p, 0)
75+
require.NoError(t, err)
76+
err = lock.Lock(ctx)
77+
require.NoError(t, err)
78+
79+
buf := bytes.NewBuffer(nil)
80+
_, err = io.Copy(buf, lock.f.Fh())
81+
require.NoError(t, err)
82+
require.NotEqual(t, data, buf, "Lock didn't write debug info to the locked file")
83+
84+
require.NoError(t, lock.Unlock())
85+
require.NoFileExists(t, p, "Unlock didn't remove the file")
86+
}
87+
88+
func TestLockError(t *testing.T) {
89+
p := filepath.Join(t.TempDir(), t.Name())
90+
lock, err := New(p, 0)
91+
require.NoError(t, err)
92+
expected := errors.New("expected")
93+
lock.f = fakeFlock{err: expected}
94+
require.Equal(t, lock.Lock(ctx), expected)
95+
}
96+
97+
func TestLockTimeout(t *testing.T) {
98+
p := filepath.Join(t.TempDir(), t.Name())
99+
a, err := New(p, 0)
100+
require.NoError(t, err)
101+
err = a.Lock(ctx)
102+
require.NoError(t, err)
103+
104+
defer func(d time.Duration) { timeout = d }(timeout)
105+
timeout = 0
106+
b, err := New(p, 0)
107+
require.NoError(t, err)
108+
109+
err = b.Lock(ctx)
110+
require.ErrorIs(t, err, context.DeadlineExceeded)
111+
112+
require.NoError(t, a.Unlock())
113+
}
114+
115+
func TestUnlockErrors(t *testing.T) {
116+
p := filepath.Join(t.TempDir(), t.Name())
117+
lock, err := New(p, 0)
118+
require.NoError(t, err)
119+
120+
err = lock.Lock(ctx)
121+
require.NoError(t, err)
122+
if runtime.GOOS != "windows" {
123+
// Remove would fail on Windows because the file lock is mandatory there
124+
require.NoError(t, os.Remove(p))
125+
}
126+
// Unlock should return nil even when the lock file has been removed
127+
require.NoError(t, lock.Unlock())
128+
129+
expected := errors.New("it didn't work")
130+
lock.f = fakeFlock{err: expected}
131+
actual := lock.Unlock()
132+
require.Equal(t, expected, actual)
133+
}

go.mod

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
module github.com/AzureAD/microsoft-authentication-extensions-for-go
2+
3+
go 1.18
4+
5+
require (
6+
github.com/stretchr/testify v1.8.2
7+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c
8+
)
9+
10+
require (
11+
github.com/davecgh/go-spew v1.1.1 // indirect
12+
github.com/kr/pretty v0.2.1 // indirect
13+
github.com/kr/text v0.1.0 // indirect
14+
github.com/pmezard/go-difflib v1.0.0 // indirect
15+
gopkg.in/yaml.v3 v3.0.1 // indirect
16+
)

go.sum

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
3+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4+
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
5+
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
6+
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
7+
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
8+
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
9+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
10+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
11+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
12+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
13+
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
14+
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
15+
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
16+
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
17+
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
18+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
19+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
20+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
21+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
22+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
23+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

0 commit comments

Comments
 (0)