Skip to content

Commit d1ecc6a

Browse files
committed
fix(desktop): serialize server changes and report partial teardown honestly
Two problems in the same transaction. The picker re-enabled Connect whenever the field changed, including while a request was in flight, so typing and pressing Enter could start a second change that interleaved its teardown and its write with the first — the later write, not a single transition, deciding the next server. The transaction is now serialized in the main process, the way the sign-out coordinator guards its own teardown, since the IPC boundary is reachable regardless of what the page does; the page keeps its button disabled for the whole request so it never asks for something it will only be refused. The stores clear independently, so one can succeed while the other fails. There is nothing to roll back to — a revoked cookie jar and deleted security-scoped bookmarks cannot be un-deleted — and moving anyway would hand the incoming deployment whatever survived. So the change is still refused, but the message no longer names only the failed store as though nothing else had happened: it says some local access may already have been cleared, and that retrying finishes the job. Clearing an already-empty store succeeds, so a retry is safe.
1 parent fc11d12 commit d1ecc6a

3 files changed

Lines changed: 106 additions & 28 deletions

File tree

apps/desktop/src/main/server-window.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,48 @@ describe('server window', () => {
114114
expect(deps.clearDeploymentScopedState).not.toHaveBeenCalled()
115115
})
116116

117+
// The picker re-enables its button while a request is pending, and the IPC
118+
// boundary is reachable regardless of what the page does, so the transaction
119+
// has to be serialized here rather than in the renderer.
120+
it('refuses a second change while one is in flight', async () => {
121+
let release: (() => void) | undefined
122+
const gate = new Promise<void>((resolve) => {
123+
release = resolve
124+
})
125+
const slow = makeDeps({
126+
clearDeploymentScopedState: vi.fn(async (): Promise<readonly string[]> => {
127+
await gate
128+
return []
129+
}),
130+
})
131+
const handle = createServerWindow(slow)
132+
133+
const first = handle.setOrigin('https://sim.other.example')
134+
const second = await handle.setOrigin('https://sim.third.example')
135+
136+
expect(second).toMatchObject({ ok: false })
137+
expect(second).toHaveProperty('error', expect.stringContaining('already in progress'))
138+
release?.()
139+
await expect(first).resolves.toMatchObject({ ok: true, unchanged: false })
140+
expect(slow.relaunch).toHaveBeenCalledTimes(1)
141+
expect(slow.config.setOrigin).toHaveBeenCalledTimes(1)
142+
expect(slow.config.setOrigin).toHaveBeenCalledWith('https://sim.other.example')
143+
})
144+
145+
// The guard must not latch: a refused change has to leave the picker usable.
146+
it('allows a later change once the first has settled', async () => {
147+
const failing = makeDeps({
148+
clearDeploymentScopedState: vi.fn(async () => ['local file access']),
149+
})
150+
const handle = createServerWindow(failing)
151+
152+
await handle.setOrigin('https://sim.other.example')
153+
const second = await handle.setOrigin('https://sim.third.example')
154+
155+
expect(second).toMatchObject({ ok: false })
156+
expect(second).toHaveProperty('error', expect.stringContaining('local file access'))
157+
})
158+
117159
// Fail closed. A store that could not be emptied is access the incoming
118160
// deployment would inherit and that startup would restore, so the change is
119161
// refused outright — and because nothing is persisted until the teardown
@@ -127,6 +169,10 @@ describe('server window', () => {
127169

128170
expect(result).toMatchObject({ ok: false })
129171
expect(result).toHaveProperty('error', expect.stringContaining('local file access'))
172+
// The stores clear independently, so the other one may already be empty and
173+
// cannot be restored. Naming only the failure would read as "nothing
174+
// happened", which is not what happened.
175+
expect(result).toHaveProperty('error', expect.stringContaining('may already have been cleared'))
130176
expect(failing.relaunch).not.toHaveBeenCalled()
131177
expect(failing.config.setOrigin).not.toHaveBeenCalled()
132178
expect(failing.config.getOrigin()).toBe(CURRENT)

apps/desktop/src/main/server-window.ts

Lines changed: 50 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,14 @@ export interface ServerWindowHandle {
100100
*/
101101
export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle {
102102
let win: BrowserWindow | null = null
103+
/**
104+
* Serializes the destructive part of a change, the way the sign-out
105+
* coordinator guards its own teardown. The picker re-enables its button
106+
* while a request is pending, and the IPC boundary is reachable regardless
107+
* of what the page does, so without this two changes could interleave their
108+
* teardown and their write and let the later write pick the next server.
109+
*/
110+
let changeInFlight = false
103111

104112
const getConfiguration = (): DesktopServerConfiguration => {
105113
const origin = deps.config.getOrigin()
@@ -177,36 +185,51 @@ export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle {
177185
return { ok: true, origin, unchanged: true }
178186
}
179187

180-
// Fail closed, and clear BEFORE persisting. If a store cannot be emptied,
181-
// the shell must not move: the incoming deployment would otherwise inherit
182-
// folder grants and authenticated browser sessions the outgoing one was
183-
// given, and they are restored on the next startup. Nothing has been
184-
// written at this point, so refusing leaves the shell exactly where it was
185-
// rather than stranding it on a half-applied change.
186-
const surviving = await deps.clearDeploymentScopedState().catch((error) => {
187-
logger.error('Deployment-scoped teardown threw', { error: getErrorMessage(error) })
188-
return ['local file access and built-in browser sessions']
189-
})
190-
if (surviving.length > 0) {
191-
logger.error('Refusing to change server; deployment-scoped state survived', { surviving })
192-
return {
193-
ok: false,
194-
error: `Could not clear ${surviving.join(' or ')} from the current server, so the server was not changed. Try again, or sign out first.`,
195-
}
188+
if (changeInFlight) {
189+
return { ok: false, error: 'A server change is already in progress.' }
196190
}
191+
changeInFlight = true
192+
try {
193+
// Fail closed, and clear BEFORE persisting. If a store cannot be emptied,
194+
// the shell must not move: the incoming deployment would otherwise
195+
// inherit folder grants and authenticated browser sessions the outgoing
196+
// one was given, and they are restored on the next startup. Nothing has
197+
// been written at this point, so refusing leaves the shell on the server
198+
// it was already using rather than half-applying the change.
199+
const surviving = await deps.clearDeploymentScopedState().catch((error) => {
200+
logger.error('Deployment-scoped teardown threw', { error: getErrorMessage(error) })
201+
return ['local file access and built-in browser sessions']
202+
})
203+
if (surviving.length > 0) {
204+
logger.error('Refusing to change server; deployment-scoped state survived', { surviving })
205+
// Deliberately describes the whole teardown, not just what failed. The
206+
// stores clear independently, so one may already be empty by now, and
207+
// there is nothing to roll back to — a revoked cookie jar and deleted
208+
// security-scoped bookmarks cannot be un-deleted. Saying "some may have
209+
// been cleared" is the honest account, and a retry is safe: clearing an
210+
// already-empty store succeeds, so it finishes the job rather than
211+
// repeating it.
212+
return {
213+
ok: false,
214+
error: `Could not clear ${surviving.join(' or ')} from the current server, so the server was not changed. Some local access may already have been cleared. Try again to finish, or sign out first.`,
215+
}
216+
}
197217

198-
logger.info('Server origin changed; relaunching', { from: current, to: origin })
199-
deps.config.setOrigin(raw)
200-
for (const key of ORIGIN_SCOPED_SETTINGS) {
201-
deps.config.set(key, undefined)
218+
logger.info('Server origin changed; relaunching', { from: current, to: origin })
219+
deps.config.setOrigin(raw)
220+
for (const key of ORIGIN_SCOPED_SETTINGS) {
221+
deps.config.set(key, undefined)
222+
}
223+
// setOrigin writes through immediately; the clears above are debounced
224+
// like every other setting. `before-quit` flushes too, but doing it here
225+
// keeps the write independent of the Electron quit sequence.
226+
deps.config.flush()
227+
close()
228+
deps.relaunch()
229+
return { ok: true, origin, unchanged: false }
230+
} finally {
231+
changeInFlight = false
202232
}
203-
// setOrigin writes through immediately; the clears above are debounced like
204-
// every other setting. `before-quit` flushes too, but doing it here keeps
205-
// the write independent of the Electron quit sequence.
206-
deps.config.flush()
207-
close()
208-
deps.relaunch()
209-
return { ok: true, origin, unchanged: false }
210233
}
211234

212235
return { open, getConfiguration, setOrigin }

apps/desktop/static/server.html

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,8 +204,14 @@ <h1>Sim server</h1>
204204
input.setAttribute('aria-invalid', tone === 'error' ? 'true' : 'false')
205205
}
206206

207+
// Set for the whole life of a request. Without it the input handler below
208+
// re-enables Connect mid-flight, letting a second change race the first.
209+
// The main process guards the transaction itself; this keeps the page
210+
// from asking for something it will only be refused.
211+
let pending = false
212+
207213
function syncConnectEnabled() {
208-
connect.disabled = input.value.trim().length === 0
214+
connect.disabled = pending || input.value.trim().length === 0
209215
}
210216

211217
input.addEventListener('input', () => {
@@ -219,6 +225,8 @@ <h1>Sim server</h1>
219225
cancel.addEventListener('click', () => window.close())
220226

221227
async function submit() {
228+
if (pending) return
229+
pending = true
222230
connect.disabled = true
223231
setMessage('Connecting…')
224232
try {
@@ -235,6 +243,7 @@ <h1>Sim server</h1>
235243
} catch {
236244
setMessage('The server could not be changed.', 'error')
237245
} finally {
246+
pending = false
238247
syncConnectEnabled()
239248
}
240249
}

0 commit comments

Comments
 (0)