Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit4689f22

Browse files
committed
feat: add file sync daemon error handling to the UI
1 parent6463de0 commit4689f22

File tree

7 files changed

+300
-80
lines changed

7 files changed

+300
-80
lines changed

‎Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ final class PreviewFileSync: FileSyncDaemon {
66

77
varstate:DaemonState=.running
88

9+
varrecentLogs:[String]=[]
10+
911
init(){}
1012

1113
func refreshSessions()async{}
1214

13-
funcstart()asyncthrows(DaemonError){
15+
functryStart()async{
1416
state=.running
1517
}
1618

‎Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift

Lines changed: 121 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
1111

1212
@Stateprivatevarloading:Bool=false
1313
@StateprivatevardeleteError:DaemonError?
14+
@StateprivatevarisVisible:Bool=false
15+
@StateprivatevardontRetry:Bool=false
1416

1517
varbody:someView{
1618
Group{
@@ -36,87 +38,138 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
3638
.frame(minWidth:400, minHeight:200)
3739
.padding(.bottom,25)
3840
.overlay(alignment:.bottom){
39-
VStack(alignment:.leading, spacing:0){
40-
Divider()
41-
HStack(spacing:0){
42-
Button{
43-
addingNewSession=true
44-
} label:{
45-
Image(systemName:"plus")
46-
.frame(width:24, height:24)
47-
}.disabled(vpn.menuState.agents.isEmpty)
41+
tableFooter
42+
}
43+
// Only the table & footer should be disabled if the daemon has crashed
44+
// otherwise the alert buttons will be disabled too
45+
}.disabled(fileSync.state.isFailed)
46+
.sheet(isPresented: $addingNewSession){
47+
FileSyncSessionModal<VPN,FS>()
48+
.frame(width:700)
49+
}.sheet(item: $editingSession){ sessionin
50+
FileSyncSessionModal<VPN,FS>(existingSession: session)
51+
.frame(width:700)
52+
}.alert("Error", isPresented:Binding(
53+
get:{ deleteError!=nil},
54+
set:{ isPresentedin
55+
if !isPresented{
56+
deleteError=nil
57+
}
58+
}
59+
)){} message:{
60+
Text(deleteError?.description??"An unknown error occurred.")
61+
}.alert("Error", isPresented:Binding(
62+
// We only show the alert if the file config window is open
63+
// Users will see the alert symbol on the menu bar to prompt them to
64+
// open it. The requirement on `!loading` prevents the alert from
65+
// re-opening immediately.
66+
get:{ !loading && isVisible && fileSync.state.isFailed},
67+
set:{ isPresentedin
68+
if !isPresented{
69+
if dontRetry{
70+
dontRetry=false
71+
return
72+
}
73+
loading=true
74+
Task{
75+
await fileSync.tryStart()
76+
loading=false
77+
}
78+
}
79+
}
80+
)){
81+
Button("Retry"){}
82+
// This gives the user an out if the daemon is crashing on launch,
83+
// they can cancel the alert, and it will reappear if they re-open the
84+
// file sync window.
85+
Button("Cancel", role:.cancel){
86+
dontRetry=true
87+
}
88+
} message:{
89+
// You can't have styled text in alert messages
90+
Text("""
91+
File sync daemon failed:\(fileSync.state.description)\n\n\(fileSync.recentLogs.joined(separator:"\n"))
92+
""")
93+
}.task{
94+
// When the Window is visible, poll for session updates every
95+
// two seconds.
96+
while !Task.isCancelled{
97+
if !fileSync.state.isFailed{
98+
await fileSync.refreshSessions()
99+
}
100+
try?awaitTask.sleep(for:.seconds(2))
101+
}
102+
}.onAppear{
103+
isVisible=true
104+
}.onDisappear{
105+
isVisible=false
106+
// If the failure alert is dismissed without restarting the daemon,
107+
// (by clicking cancel) this makes it clear that the daemon
108+
// is still in a failed state.
109+
}.navigationTitle("Coder File Sync\(fileSync.state.isFailed?"- Failed":"")")
110+
.disabled(loading)
111+
}
112+
113+
vartableFooter:someView{
114+
VStack(alignment:.leading, spacing:0){
115+
Divider()
116+
HStack(spacing:0){
117+
Button{
118+
addingNewSession=true
119+
} label:{
120+
Image(systemName:"plus")
121+
.frame(width:24, height:24)
122+
}.disabled(vpn.menuState.agents.isEmpty)
123+
Divider()
124+
Button{
125+
Task{
126+
loading=true
127+
defer{ loading=false}
128+
dothrows(DaemonError){
129+
// TODO: Support selecting & deleting multiple sessions at once
130+
tryawait fileSync.deleteSessions(ids:[selection!])
131+
if fileSync.sessionState.isEmpty{
132+
// Last session was deleted, stop the daemon
133+
await fileSync.stop()
134+
}
135+
} catch{
136+
deleteError= error
137+
}
138+
selection=nil
139+
}
140+
} label:{
141+
Image(systemName:"minus").frame(width:24, height:24)
142+
}.disabled(selection==nil)
143+
iflet selection{
144+
iflet selectedSession= fileSync.sessionState.first(where:{ $0.id== selection}){
48145
Divider()
49146
Button{
50147
Task{
148+
// TODO: Support pausing & resuming multiple sessions at once
51149
loading=true
52150
defer{ loading=false}
53-
dothrows(DaemonError){
54-
// TODO: Support selecting & deleting multiple sessions at once
55-
tryawait fileSync.deleteSessions(ids:[selection!])
56-
if fileSync.sessionState.isEmpty{
57-
// Last session was deleted, stop the daemon
58-
await fileSync.stop()
59-
}
60-
} catch{
61-
deleteError= error
151+
switch selectedSession.status{
152+
case.paused:
153+
tryawait fileSync.resumeSessions(ids:[selectedSession.id])
154+
default:
155+
tryawait fileSync.pauseSessions(ids:[selectedSession.id])
62156
}
63-
selection=nil
64157
}
65158
} label:{
66-
Image(systemName:"minus").frame(width:24, height:24)
67-
}.disabled(selection==nil)
68-
iflet selection{
69-
iflet selectedSession= fileSync.sessionState.first(where:{ $0.id== selection}){
70-
Divider()
71-
Button{
72-
Task{
73-
// TODO: Support pausing & resuming multiple sessions at once
74-
loading=true
75-
defer{ loading=false}
76-
switch selectedSession.status{
77-
case.paused:
78-
tryawait fileSync.resumeSessions(ids:[selectedSession.id])
79-
default:
80-
tryawait fileSync.pauseSessions(ids:[selectedSession.id])
81-
}
82-
}
83-
} label:{
84-
switch selectedSession.status{
85-
case.paused:
86-
Image(systemName:"play").frame(width:24, height:24)
87-
default:
88-
Image(systemName:"pause").frame(width:24, height:24)
89-
}
90-
}
159+
switch selectedSession.status{
160+
case.paused:
161+
Image(systemName:"play").frame(width:24, height:24)
162+
default:
163+
Image(systemName:"pause").frame(width:24, height:24)
91164
}
92165
}
93166
}
94-
.buttonStyle(.borderless)
95167
}
96-
.background(.primary.opacity(0.04))
97-
.fixedSize(horizontal:false, vertical:true)
98-
}
99-
}.sheet(isPresented: $addingNewSession){
100-
FileSyncSessionModal<VPN,FS>()
101-
.frame(width:700)
102-
}.sheet(item: $editingSession){ sessionin
103-
FileSyncSessionModal<VPN,FS>(existingSession: session)
104-
.frame(width:700)
105-
}.alert("Error", isPresented:Binding(
106-
get:{ deleteError!=nil},
107-
set:{ isPresentedin
108-
if !isPresented{
109-
deleteError=nil
110-
}
111-
}
112-
)){} message:{
113-
Text(deleteError?.description??"An unknown error occurred.")
114-
}.task{
115-
while !Task.isCancelled{
116-
await fileSync.refreshSessions()
117-
try?awaitTask.sleep(for:.seconds(2))
118168
}
119-
}.disabled(loading)
169+
.buttonStyle(.borderless)
170+
}
171+
.background(.primary.opacity(0.04))
172+
.fixedSize(horizontal:false, vertical:true)
120173
}
121174
}
122175

‎Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,12 @@ struct VPNMenu<VPN: VPNService, FS: FileSyncDaemon>: View {
6868
} label:{
6969
ButtonRowView{
7070
HStack{
71-
// TODO: A future PR will provide users a way to recover from a daemon failure without
72-
// needing to restart the app
73-
if case.failed= fileSync.state,sessionsHaveError(fileSync.sessionState){
71+
if fileSync.state.isFailed ||sessionsHaveError(fileSync.sessionState){
7472
Image(systemName:"exclamationmark.arrow.trianglehead.2.clockwise.rotate.90")
75-
.frame(width:12, height:12).help("One or more sync sessions have errors")
73+
.frame(width:12, height:12)
74+
.help(fileSync.state.isFailed?
75+
"The file sync daemon encountered an error":
76+
"One or more file sync sessions have errors")
7677
}
7778
Text("File sync")
7879
}

‎Coder-Desktop/Coder-DesktopTests/Util.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,13 @@ class MockFileSyncDaemon: FileSyncDaemon {
3333

3434
func refreshSessions()async{}
3535

36+
varrecentLogs:[String]=[]
37+
3638
func deleteSessions(ids _:[String])asyncthrows(VPNLib.DaemonError){}
3739

3840
varstate:VPNLib.DaemonState=.running
3941

40-
func start()asyncthrows(VPNLib.DaemonError){
41-
return
42-
}
42+
func tryStart()async{}
4343

4444
func stop()async{}
4545

‎Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import SwiftUI
1010
publicprotocolFileSyncDaemon:ObservableObject{
1111
varstate:DaemonState{get}
1212
varsessionState:[FileSyncSession]{get}
13-
func start()asyncthrows(DaemonError)
13+
varrecentLogs:[String]{get}
14+
func tryStart()async
1415
func stop()async
1516
func refreshSessions()async
1617
func createSession(localPath:String, agentHost:String, remotePath:String)asyncthrows(DaemonError)
@@ -38,6 +39,10 @@ public class MutagenDaemon: FileSyncDaemon {
3839

3940
@PublishedpublicvarsessionState:[FileSyncSession]=[]
4041

42+
// We store the last N log lines to show in the UI if the daemon crashes
43+
privatevarlogBuffer:RingBuffer<String>
44+
publicvarrecentLogs:[String]{ logBuffer.elements}
45+
4146
privatevarmutagenProcess:Subprocess?
4247
privateletmutagenPath:URL!
4348
privateletmutagenDataDirectory:URL
@@ -50,6 +55,7 @@ public class MutagenDaemon: FileSyncDaemon {
5055
varclient:DaemonClient?
5156
privatevargroup:MultiThreadedEventLoopGroup?
5257
privatevarchannel:GRPCChannel?
58+
privatevarwaitForExit:(@Sendable()async->Void)?
5359

5460
// Protect start & stop transitions against re-entrancy
5561
privatelettransition=AsyncSemaphore(value:1)
@@ -58,8 +64,10 @@ public class MutagenDaemon: FileSyncDaemon {
5864
mutagenDataDirectory:URL=FileManager.default.urls(
5965
for:.applicationSupportDirectory,
6066
in:.userDomainMask
61-
).first!.appending(path:"Coder Desktop").appending(path:"Mutagen"))
67+
).first!.appending(path:"Coder Desktop").appending(path:"Mutagen"),
68+
logBufferCapacity:Int=10)
6269
{
70+
logBuffer=.init(capacity: logBufferCapacity)
6371
self.mutagenPath= mutagenPath
6472
self.mutagenDataDirectory= mutagenDataDirectory
6573
mutagenDaemonSocket= mutagenDataDirectory.appending(path:"daemon").appending(path:"daemon.sock")
@@ -87,13 +95,31 @@ public class MutagenDaemon: FileSyncDaemon {
8795
}
8896
}
8997

90-
publicfunc start()asyncthrows(DaemonError){
98+
publicfunc tryStart()async{
99+
if case.failed= state{ state=.stopped}
100+
dothrows(DaemonError){
101+
tryawaitstart()
102+
} catch{
103+
state=.failed(error)
104+
}
105+
}
106+
107+
func start()asyncthrows(DaemonError){
91108
if case.unavailable= state{return}
92109

93110
// Stop an orphaned daemon, if there is one
94111
try?awaitconnect()
95112
awaitstop()
96113

114+
// Creating the same process twice from Swift will crash the MainActor,
115+
// so we need to wait for an earlier process to die
116+
iflet waitForExit{
117+
awaitwaitForExit()
118+
// We *need* to be sure the process is dead or the app ends up in an
119+
// unrecoverable state
120+
try?awaitTask.sleep(for:.seconds(1))
121+
}
122+
97123
await transition.wait()
98124
defer{ transition.signal()}
99125
logger.info("starting mutagen daemon")
@@ -106,6 +132,7 @@ public class MutagenDaemon: FileSyncDaemon {
106132
}catch{
107133
throw.daemonStartFailure(error)
108134
}
135+
self.waitForExit= waitForExit
109136

110137
Task{
111138
awaitstreamHandler(io: standardOutput)
@@ -259,6 +286,7 @@ public class MutagenDaemon: FileSyncDaemon {
259286
privatefunc streamHandler(io:Pipe.AsyncBytes)async{
260287
forawaitlinein io.lines{
261288
logger.info("\(line, privacy:.public)")
289+
logBuffer.append(line)
262290
}
263291
}
264292
}
@@ -282,7 +310,7 @@ public enum DaemonState {
282310
case.stopped:
283311
"Stopped"
284312
caselet.failed(error):
285-
"Failed:\(error)"
313+
"\(error.description)"
286314
case.unavailable:
287315
"Unavailable"
288316
}
@@ -300,6 +328,15 @@ public enum DaemonState {
300328
.gray
301329
}
302330
}
331+
332+
// `if case`s are a pain to work with: they're not bools (such as for ORing)
333+
// and you can't negate them without doing `if case .. {} else`.
334+
publicvarisFailed:Bool{
335+
if case.failed=self{
336+
returntrue
337+
}
338+
returnfalse
339+
}
303340
}
304341

305342
publicenumDaemonError:Error{

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp