[gsession] StorageFile.SetSession 非原子写入导致并发读请求看到空 session
Maintainers usually reply within 2 days
Nobody has claimed this yet.
- #4814 by @waterWang — closed without merging
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 74/100
Research direction
Read the two paths in gsession_storage_file.go: SetSession around lines 189-203 (O_TRUNC open then two separate Write calls) and GetSession at 141-170, which treats content of 8 bytes or fewer as an empty session. Reproduce the race first with a test that calls GetSession concurrently while SetSession rewrites the same session ID, and keep the 8-byte gtime.TimestampMilli() prefix format intact. Check the closed, unmerged PR #4814 for a prior attempt before starting. Done means SetSession writes a temp file and renames it atomically, with existing gsession storage tests still passing.
Written by the indexing model from the issue text.
Description
Description
StorageFile.SetSession writes session data using a non-atomic file write pattern (open with O_TRUNC then two separate Write calls), creating a race window where concurrent readers see an empty or partially-written file.
Impact
Under concurrent requests sharing the same session ID (common in SPA applications where page switching fires multiple API calls simultaneously), intermittent authentication failures occur:
- Session data is lost;
GetSessionreturns nil - User appears "not logged in" and gets redirected to login
Root Cause
Write path (gsession_storage_file.go:189-203)
file, err := gfile.OpenWithFlagPerm(
path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.ModePerm,
)
// ...
file.Write(gbinary.EncodeInt64(gtime.TimestampMilli())) // 8 bytes timestamp
file.Write(content) // JSON body
O_TRUNC empties the file immediately. Then two separate file.Write() calls create a real gap between writing the timestamp header and the JSON body.
Read path (gsession_storage_file.go:141-170)
content = gfile.GetBytes(path)
if len(content) > 8 {
// parse JSON
}
return nil, nil // content <= 8 bytes → empty session
Race window
Writer(SetSession) Reader(GetSession)
│ │
├─O_TRUNC open → file is empty │
│ ├─gfile.GetBytes() → 0-8 bytes
│ │ len<=8 → returns nil session
│ │ → user appears not logged in!
├─file.Write(timestamp)[8 bytes] │
├─file.Write(content)[JSON body] │
│ write complete │
Production Evidence
Real log (3ms window, same session ID):
12:16:47.467 devices/all userId=1 ← normal
12:16:47.470 software-systems/all userId=0 ← user_id key missing! (race hit)
12:16:47.472 network-lines/all userId=1 ← normal
Proposed Fix: atomic write via temp file + rename
func (s *StorageFile) SetSession(ctx context.Context, sessionId string, sessionData *gmap.StrAnyMap, ttl time.Duration) error {
intlog.Printf(ctx, "StorageFile.SetSession: %s, %v, %v", sessionId, sessionData, ttl)
path := s.sessionFilePath(sessionId)
content, err := json.Marshal(sessionData)
if err != nil {
return err
}
if s.cryptoEnabled {
content, err = gaes.Encrypt(content, DefaultStorageFileCryptoKey)
if err != nil {
return err
}
}
// Build content with 8-byte timestamp prefix (same format)
timestamp := gbinary.EncodeInt64(gtime.TimestampMilli())
data := make([]byte, 8+len(content))
copy(data[:8], timestamp)
copy(data[8:], content)
// Step 1: write to temp file (invisible to readers)
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, data, os.ModePerm); err != nil {
return gerror.Wrapf(err, `write data failed to file "%s"`, tmpPath)
}
// Step 2: atomic rename (appears atomically to readers)
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return gerror.Wrapf(err, `rename "%s" -> "%s" failed`, tmpPath, path)
}
return nil
}
os.Rename is atomic on the same filesystem (POSIX guarantee), so readers always see a complete file.
- Dominant language
- Go
- Stars
- 13.3k
- Forks
- 1.7k
- Avg merge
- 4d 11h
- Merged PRs (30d)
- 42
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from gogf/gf
-
feature inactive
Difficulty 2/5 1-3 hours Newbie friendliness 66/100
Maintainers usually reply within 2 days
-
cmd/gf: gen ctrl merge mode takes an unaliased import ending in the api version as the api packageOpen
Difficulty 3/5 1-2 days Newbie friendliness 72/100
Maintainers usually reply within 2 days
-
Difficulty 4/5 3-5 days Newbie friendliness 45/100
Maintainers usually reply within 2 days
-
database/gdb: Model.Partition() is a no-op, partition names never reach the generated SQLMay be free again A pull request for this issue was closed without being merged. Openbug discuss help wanted
Difficulty 4/5 3-5 days Newbie friendliness 25/100
Maintainers usually reply within 2 days
-
gdb: `Result.Structs` / `Model.Scan` into `*[]map[string]interface{}` silently returns empty maps instead of data or errorPossibly taken @LuisFigueroaG claimed this 1 day ago. Open
Difficulty 3/5 1-2 days Newbie friendliness 45/100
Maintainers usually reply within 2 days
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
lognorman20/monaco#7428 ·
-
docs(prompts): IF BLOCKED workflow-scope branch says to comment on the issue on jira/local trackersOpenagent-butler-finding bug
Difficulty 2/5 1-3 hours Newbie friendliness 77/100
jordansmall/spindrift#6238 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
microcks/microcks-cli#608 · 1 comment ·
Maintainers usually reply within 1 day
-
bug from-studio
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
esengine/DeepSeek-Reasonix#14868 ·
Maintainers usually reply within 1 day