Repository navigation
fix: prevent workspace.json race breaking VSCode browser create flow #431
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
912b4b3
12281f3
9f50e10
71ce84a
5eaf583
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| // Package exitcode defines process exit codes used as a contract between | ||
| // devsy and any parent process that spawns it as a subprocess (e.g. the | ||
| // backhaul SSH command). Keeping these in a leaf package avoids import | ||
| // cycles between the packages that emit and consume them. | ||
| package exitcode | ||
|
|
||
| // WorkspaceNotFound is emitted when devsy exits because the requested | ||
| // workspace could not be located. Parent processes may treat this as a | ||
| // transient signal (e.g. during a workspace-registration race) and retry. | ||
| const WorkspaceNotFound = 75 |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,45 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||
| package provider | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| import ( | ||||||||||||||||||||||||||||||||||||||||||||||||
| "fmt" | ||||||||||||||||||||||||||||||||||||||||||||||||
| "os" | ||||||||||||||||||||||||||||||||||||||||||||||||
| "path/filepath" | ||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // WriteFileAtomic writes data to path atomically by writing to a sibling | ||||||||||||||||||||||||||||||||||||||||||||||||
| // temp file then renaming. Readers either see the old content or the new | ||||||||||||||||||||||||||||||||||||||||||||||||
| // content, never a partially-written file. | ||||||||||||||||||||||||||||||||||||||||||||||||
| func WriteFileAtomic(path string, data []byte, perm os.FileMode) error { | ||||||||||||||||||||||||||||||||||||||||||||||||
| dir := filepath.Dir(path) | ||||||||||||||||||||||||||||||||||||||||||||||||
| tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*") | ||||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return fmt.Errorf("create temp file: %w", err) | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| tmpName := tmp.Name() | ||||||||||||||||||||||||||||||||||||||||||||||||
| cleanup := func() { _ = os.Remove(tmpName) } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if _, err := tmp.Write(data); err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||
| _ = tmp.Close() | ||||||||||||||||||||||||||||||||||||||||||||||||
| cleanup() | ||||||||||||||||||||||||||||||||||||||||||||||||
| return fmt.Errorf("write temp file: %w", err) | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| if err := tmp.Chmod(perm); err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||
| _ = tmp.Close() | ||||||||||||||||||||||||||||||||||||||||||||||||
| cleanup() | ||||||||||||||||||||||||||||||||||||||||||||||||
| return fmt.Errorf("chmod temp file: %w", err) | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| if err := tmp.Sync(); err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||
| _ = tmp.Close() | ||||||||||||||||||||||||||||||||||||||||||||||||
| cleanup() | ||||||||||||||||||||||||||||||||||||||||||||||||
| return fmt.Errorf("sync temp file: %w", err) | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| if err := tmp.Close(); err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||
| cleanup() | ||||||||||||||||||||||||||||||||||||||||||||||||
| return fmt.Errorf("close temp file: %w", err) | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| if err := os.Rename(tmpName, path); err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||
| cleanup() | ||||||||||||||||||||||||||||||||||||||||||||||||
| return fmt.Errorf("rename temp file: %w", err) | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| return nil | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+50
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Durability gap after rename can still lose the new file on crash After Line 40, the code returns without syncing the parent directory. A crash/power loss can drop the rename metadata, so this is not fully crash-safe persistence yet. 💡 Suggested fix func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
@@
if err := os.Rename(tmpName, path); err != nil {
cleanup()
return fmt.Errorf("rename temp file: %w", err)
}
+
+ dirHandle, err := os.Open(dir)
+ if err != nil {
+ return fmt.Errorf("open parent dir: %w", err)
+ }
+ if err := dirHandle.Sync(); err != nil {
+ _ = dirHandle.Close()
+ return fmt.Errorf("sync parent dir: %w", err)
+ }
+ if err := dirHandle.Close(); err != nil {
+ return fmt.Errorf("close parent dir: %w", err)
+ }
+
return nil
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Documented as an explicit non-guarantee in 5eaf583 rather than adding the parent-dir fsync. The original race this PR fixes is in-process concurrent-reader atomicity, which POSIX There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
✏️ Learnings added
|
||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| package provider | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "os" | ||
| "path/filepath" | ||
| "sync" | ||
| "sync/atomic" | ||
| "testing" | ||
| "time" | ||
| ) | ||
|
|
||
| func TestWriteFileAtomic_ConcurrentReadersSeeNoPartialWrites(t *testing.T) { | ||
| dir := t.TempDir() | ||
| path := filepath.Join(dir, "workspace.json") | ||
|
|
||
| // Seed with valid JSON so the first reads succeed. | ||
| if err := WriteFileAtomic(path, []byte(`{"n":0}`), 0o600); err != nil { | ||
| t.Fatalf("seed: %v", err) | ||
| } | ||
|
|
||
| var stop atomic.Bool | ||
| var wg sync.WaitGroup | ||
| readerErr := make(chan error, 4) | ||
|
|
||
| wg.Go(func() { atomicWriter(t, &stop, path) }) | ||
| for range 3 { | ||
| wg.Go(func() { atomicReader(&stop, path, readerErr) }) | ||
| } | ||
|
|
||
| time.Sleep(200 * time.Millisecond) | ||
| stop.Store(true) | ||
| wg.Wait() | ||
| close(readerErr) | ||
|
|
||
| for err := range readerErr { | ||
| t.Fatalf("reader observed partial write: %v", err) | ||
| } | ||
| } | ||
|
|
||
| func atomicWriter(t *testing.T, stop *atomic.Bool, path string) { | ||
| t.Helper() | ||
| for i := 0; !stop.Load(); i++ { | ||
| payload, _ := json.Marshal(map[string]int{"n": i}) | ||
| if err := WriteFileAtomic(path, payload, 0o600); err != nil { | ||
| t.Errorf("write: %v", err) | ||
| return | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func atomicReader(stop *atomic.Bool, path string, out chan<- error) { | ||
| for !stop.Load() { | ||
| data, err := os.ReadFile(path) //nolint:gosec // test reads a path under t.TempDir | ||
| if err != nil { | ||
| if os.IsNotExist(err) { | ||
| continue | ||
| } | ||
| out <- err | ||
| return | ||
| } | ||
| var v map[string]int | ||
| if err := json.Unmarshal(data, &v); err != nil { | ||
| out <- err | ||
| return | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| package ssh | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "net" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "golang.org/x/crypto/ssh" | ||
| ) | ||
|
|
||
| // noopForward is a ForwardingFunction that immediately closes the local | ||
| // connection. This lets portForwarding exercise its idle/cancellation paths | ||
| // without needing a real *ssh.Client. | ||
| func noopForward(localConn net.Conn, _ *ssh.Client, _, _ string) { | ||
| _ = localConn.Close() | ||
| } | ||
|
|
||
| // TestPortForwarding_IdleTimeoutReturnsErrIdleTimeout verifies that when the | ||
| // idle timeout fires after a connection has come and gone, portForwarding | ||
| // returns ErrIdleTimeout. | ||
| func TestPortForwarding_IdleTimeoutReturnsErrIdleTimeout(t *testing.T) { | ||
| lis, err := net.Listen("tcp", "127.0.0.1:0") | ||
| if err != nil { | ||
| t.Fatalf("failed to listen: %v", err) | ||
| } | ||
|
|
||
| ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) | ||
| defer cancel() | ||
|
|
||
| errCh := make(chan error, 1) | ||
| go func() { | ||
| errCh <- portForwarding( | ||
| ctx, nil, lis, | ||
| lis.Addr().String(), "tcp", "127.0.0.1:0", | ||
| 15*time.Millisecond, noopForward, | ||
| ) | ||
| }() | ||
|
|
||
| // Open and close a connection so the connection counter starts the idle | ||
| // timer when it returns to zero. | ||
| conn, err := net.Dial("tcp", lis.Addr().String()) | ||
| if err != nil { | ||
| t.Fatalf("failed to dial listener: %v", err) | ||
| } | ||
| _ = conn.Close() | ||
|
|
||
| select { | ||
| case got := <-errCh: | ||
| if !errors.Is(got, ErrIdleTimeout) { | ||
| t.Fatalf("expected ErrIdleTimeout, got %v", got) | ||
| } | ||
| case <-time.After(200 * time.Millisecond): | ||
| t.Fatal("timed out waiting for portForwarding to return") | ||
| } | ||
| } | ||
|
|
||
| // TestPortForwarding_ParentCancelNotIdleTimeout verifies that when the parent | ||
| // context is canceled, the returned error is NOT ErrIdleTimeout. | ||
| func TestPortForwarding_ParentCancelNotIdleTimeout(t *testing.T) { | ||
| lis, err := net.Listen("tcp", "127.0.0.1:0") | ||
| if err != nil { | ||
| t.Fatalf("failed to listen: %v", err) | ||
| } | ||
|
|
||
| ctx, cancel := context.WithCancel(context.Background()) | ||
|
|
||
| errCh := make(chan error, 1) | ||
| go func() { | ||
| errCh <- portForwarding( | ||
| ctx, nil, lis, | ||
| lis.Addr().String(), "tcp", "127.0.0.1:0", | ||
| // Use a long idle timeout so it cannot fire during the test. | ||
| 10*time.Second, noopForward, | ||
| ) | ||
| }() | ||
|
|
||
| // Cancel before any idle timer could be relevant. | ||
| cancel() | ||
|
|
||
| select { | ||
| case got := <-errCh: | ||
| if errors.Is(got, ErrIdleTimeout) { | ||
| t.Fatalf("expected non-ErrIdleTimeout error on parent cancel, got %v", got) | ||
| } | ||
| if got == nil { | ||
| t.Fatal("expected a non-nil error on parent cancel") | ||
| } | ||
| case <-time.After(200 * time.Millisecond): | ||
| t.Fatal("timed out waiting for portForwarding to return") | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.