Skip to content

Commit c97e97b

Browse files
committed
feat(UI) Handle starting/terminating states and block duplicate instance launches
1 parent 725b205 commit c97e97b

File tree

3 files changed

+137
-25
lines changed

3 files changed

+137
-25
lines changed

api/instance.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,28 @@ def post(): # pylint: disable=too-many-return-statements,too-many-branches
140140
return {"success": False, "data": {"message": "unauthorized"}}, 403
141141

142142
# check if source_id can launch the instance
143+
lock_acquired = False
144+
lock = None
143145
try:
144146
lock = load_or_store(str(source_id))
145147
logger.debug("post /instance acquire the player lock for %s", source_id)
146-
lock.player_lock()
148+
lock_acquired = lock.player_lock(blocking=False)
149+
if not lock_acquired:
150+
logger.info(
151+
"instance creation already in progress for challenge %s / source %s",
152+
challenge_id,
153+
source_id,
154+
)
155+
return (
156+
{
157+
"success": False,
158+
"data": {
159+
"code": 6,
160+
"message": "instance creation already in progress",
161+
},
162+
},
163+
409,
164+
)
147165

148166
if not check_source_can_create_instance(challenge_id, source_id):
149167
return (
@@ -186,8 +204,9 @@ def post(): # pylint: disable=too-many-return-statements,too-many-branches
186204
}, 500
187205

188206
finally:
189-
logger.debug("post /instance release the player lock for %s", source_id)
190-
lock.player_unlock()
207+
if lock_acquired and lock is not None:
208+
logger.debug("post /instance release the player lock for %s", source_id)
209+
lock.player_unlock(skip_rw=True)
191210

192211
# return only necessary values
193212
data = {}

assets/view.js

Lines changed: 101 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,72 @@ CTFd._internal.challenge.postRender = function () {
1313

1414
if (window.$ === undefined) window.$ = CTFd.lib.$;
1515

16+
const BOOT_DEFAULT_LABEL = "Launch the challenge";
17+
const BOOT_STARTING_LABEL = "Starting...";
18+
const STARTING_KEY_PREFIX = "cm-starting:";
19+
const STARTING_TTL_MS = 10 * 60 * 1000;
20+
21+
function getStartingKey(challengeId) {
22+
return `${STARTING_KEY_PREFIX}${challengeId}`;
23+
}
24+
25+
function markInstanceStarting(challengeId) {
26+
try {
27+
localStorage.setItem(
28+
getStartingKey(challengeId),
29+
JSON.stringify({ ts: Date.now() }),
30+
);
31+
} catch (e) {
32+
// storage unavailable, nothing to do
33+
}
34+
}
35+
36+
function clearInstanceStarting(challengeId) {
37+
try {
38+
localStorage.removeItem(getStartingKey(challengeId));
39+
} catch (e) {
40+
// storage unavailable, nothing to do
41+
}
42+
}
43+
44+
function isInstanceStarting(challengeId) {
45+
try {
46+
const raw = localStorage.getItem(getStartingKey(challengeId));
47+
if (!raw) return false;
48+
49+
const parsed = JSON.parse(raw);
50+
if (!parsed.ts) return false;
51+
52+
const age = Date.now() - parsed.ts;
53+
if (age > STARTING_TTL_MS) {
54+
clearInstanceStarting(challengeId);
55+
return false;
56+
}
57+
return true;
58+
} catch (e) {
59+
return false;
60+
}
61+
}
62+
63+
function setBootButtonStarting() {
64+
$('#whale-button-boot').text(BOOT_STARTING_LABEL);
65+
$('#whale-button-boot').prop('disabled', true);
66+
}
67+
68+
function resetBootButton() {
69+
$('#whale-button-boot').text(BOOT_DEFAULT_LABEL);
70+
$('#whale-button-boot').prop('disabled', false);
71+
}
72+
73+
function renderStartingState() {
74+
$('#cm-panel-loading').hide();
75+
$('#cm-panel-until').hide();
76+
$('#whale-panel-started').hide();
77+
$('#whale-panel-stopped').show();
78+
$('#whale-challenge-lan-domain').html('');
79+
setBootButtonStarting();
80+
}
81+
1682
function formatCountDown(countdown) {
1783

1884
// Convert
@@ -42,7 +108,12 @@ function formatCountDown(countdown) {
42108
function loadInfo() {
43109
var challenge_id = CTFd._internal.challenge.data.id;
44110
var url = "/api/v1/plugins/ctfd-chall-manager/instance?challengeId=" + challenge_id;
45-
111+
const pendingStart = isInstanceStarting(challenge_id);
112+
if (pendingStart) {
113+
setBootButtonStarting();
114+
} else {
115+
resetBootButton();
116+
}
46117

47118
CTFd.fetch(url, {
48119
method: 'GET',
@@ -67,11 +138,19 @@ function loadInfo() {
67138
clearInterval(window.t);
68139
window.t = undefined;
69140
}
70-
if (response.success) response = response.data;
71-
else CTFd._functions.events.eventAlert({
72-
title: "Fail",
73-
html: response.data.message,
74-
});
141+
if (response.success) {
142+
response = response.data;
143+
clearInstanceStarting(challenge_id);
144+
} else {
145+
const code = response.data?.code || response.code;
146+
if (pendingStart && (code === 5 || code === 404 || code === 14)) {
147+
renderStartingState();
148+
return;
149+
}
150+
clearInstanceStarting(challenge_id);
151+
renderErrorAlert(response);
152+
return;
153+
}
75154
$('#cm-panel-loading').hide();
76155
$('#cm-panel-until').hide();
77156

@@ -100,9 +179,14 @@ function loadInfo() {
100179
$('#whale-challenge-count-down').text(formatCountDown(count_down));
101180
}, 1000);
102181
} else {
103-
$('#whale-panel-started').hide(); // hide the panel instance is up
104-
$('#whale-panel-stopped').show(); // show the panel instance is down
105-
$('#whale-challenge-lan-domain').html('');
182+
// expired but likely still being cleaned up server-side
183+
$('#whale-panel-started').show(); // show the panel instance is up
184+
$('#whale-panel-stopped').hide(); // hide the panel instance is down
185+
$('#whale-challenge-lan-domain').html(response.connectionInfo || '');
186+
$('#whale-challenge-count-down').text('terminating...');
187+
$('#cm-panel-until').show();
188+
// prevent booting a second instance while termination pending
189+
$('#whale-button-boot').prop('disabled', true).text('Terminating...');
106190
}
107191

108192
} else if (response.since) { // if instance has no until
@@ -113,6 +197,7 @@ function loadInfo() {
113197
$('#whale-panel-started').hide(); // hide the panel instance is up
114198
$('#whale-panel-stopped').show(); // show the panel instance is down
115199
$('#whale-challenge-lan-domain').html('');
200+
resetBootButton();
116201
}
117202

118203

@@ -261,6 +346,8 @@ CTFd._internal.challenge.boot = function() {
261346

262347
$('#whale-button-boot').text("Waiting...");
263348
$('#whale-button-boot').prop('disabled', true);
349+
markInstanceStarting(challenge_id);
350+
setBootButtonStarting();
264351

265352
var params = {
266353
"challengeId": challenge_id.toString()
@@ -286,18 +373,13 @@ CTFd._internal.challenge.boot = function() {
286373
title: "Success",
287374
html: "Your instance has been deployed!",
288375
});
376+
clearInstanceStarting(challenge_id);
289377
resolve();
290378
} else {
291-
CTFd._functions.events.eventAlert({
292-
title: "Fail",
293-
html: response.data.message,
294-
});
295-
}
296-
}).catch(error => {
379+
297380
reject(error);
298-
}).finally(() => {
299-
$('#whale-button-boot').text("Launch an instance");
300-
$('#whale-button-boot').prop('disabled', false);
381+
}.finally(() => {
382+
resetBootButton();
301383
});
302384
});
303385
};
@@ -352,4 +434,4 @@ CTFd._internal.challenge.submit = function(preview) {
352434
}
353435
return response
354436
})
355-
};
437+
};

utils/mana_lock.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,22 +56,33 @@ def __init__(self, name: str):
5656
def __repr__(self):
5757
return f"ManaLock name={self.name}"
5858

59-
def player_lock(self):
59+
def player_lock(self, blocking: bool = True) -> bool:
6060
"""
6161
Acquires the lock for a player.
62+
63+
``blocking`` (bool): attempt to acquire without waiting when False.
64+
65+
Returns True if the lock was acquired, False otherwise.
6266
"""
67+
if not blocking:
68+
return self.gr.acquire(blocking=False)
69+
6370
if rw_lock_enabled:
6471
self.rw.r_lock()
6572

6673
self.gr.acquire()
74+
return True
6775

68-
def player_unlock(self):
76+
def player_unlock(self, *, skip_rw: bool = False):
6977
"""
7078
Releases the lock for a player.
79+
80+
``skip_rw`` (bool): if True, do not release the RW lock
81+
(used when ``player_lock`` was called with ``blocking=False``).
7182
"""
7283
self.gr.release()
7384

74-
if rw_lock_enabled:
85+
if rw_lock_enabled and not skip_rw:
7586
self.rw.r_unlock()
7687

7788
def admin_lock(self):

0 commit comments

Comments
 (0)