Skip to content

Commit 037c960

Browse files
committed
raise error when output after page close
1 parent 95f07aa commit 037c960

File tree

10 files changed

+155
-40
lines changed

10 files changed

+155
-40
lines changed

docs/spec.rst

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,4 +461,10 @@ js_yield
461461
^^^^^^^^^^^^^^^
462462
submit data from js. It's a common event to submit data to backend.
463463

464-
The ``data`` of the event is the data need to submit
464+
The ``data`` of the event is the data need to submit
465+
466+
page_close
467+
^^^^^^^^^^^^^^^
468+
Triggered when the user close the page
469+
470+
The ``data`` of the event is the page id that is closed

pywebio/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ class SessionException(Exception):
1010
"""Base class for PyWebIO session related exceptions"""
1111

1212

13+
class PageClosedException(Exception):
14+
"""The page has been closed abnormally"""
15+
16+
1317
class SessionClosedException(SessionException):
1418
"""The session has been closed abnormally"""
1519

pywebio/io_ctrl.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,11 @@ def safely_destruct(cls, obj):
6363
pass
6464

6565
def __init__(self, spec, on_embed=None):
66-
self.processed = False
66+
self.processed = True # avoid `__del__` is invoked accidentally when exception occurs in `__init__`
6767
self.on_embed = on_embed or (lambda d: d)
6868
try:
6969
self.spec = type(self).dump_dict(spec) # this may raise TypeError
7070
except TypeError:
71-
self.processed = True
7271
type(self).safely_destruct(spec)
7372
raise
7473

@@ -84,7 +83,12 @@ def __init__(self, spec, on_embed=None):
8483
# the Exception raised from there will be ignored by python interpreter,
8584
# thus we can't end some session in some cases.
8685
# See also: https://github.com/pywebio/PyWebIO/issues/243
87-
get_current_session()
86+
s = get_current_session()
87+
88+
# Try to make sure current page is active.
89+
# Session.get_page_id will raise PageClosedException when the page is not activate
90+
s.get_page_id()
91+
self.processed = False
8892

8993
def enable_context_manager(self, container_selector=None, container_dom_id=None, custom_enter=None,
9094
custom_exit=None):

pywebio/output.py

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@
220220
from .io_ctrl import output_register_callback, send_msg, Output, safely_destruct_output_when_exp, OutputList, scope2dom
221221
from .session import get_current_session, download
222222
from .utils import random_str, iscoroutinefunction, check_dom_name_value
223+
from .exceptions import PageClosedException
223224

224225
try:
225226
from PIL.Image import Image as PILImage
@@ -1813,10 +1814,12 @@ async def coro_wrapper(*args, **kwargs):
18131814
return wrapper
18141815

18151816

1816-
def page(func=None):
1817+
def page(silent_quit=False):
18171818
"""
18181819
Open a page. Can be used as context manager and decorator.
18191820
1821+
:param bool silent_quit: whether to quit silently when the page is closed accidentally by app user
1822+
18201823
:Usage:
18211824
18221825
::
@@ -1825,19 +1828,19 @@ def page(func=None):
18251828
input()
18261829
put_xxx()
18271830
1828-
@page() # or @page
1831+
@page()
18291832
def content():
18301833
input()
18311834
put_xxx()
18321835
"""
1833-
1834-
if func is None:
1835-
return page_()
1836-
return page_()(func)
1836+
p = page_()
1837+
p.silent_quit = silent_quit
1838+
return p
18371839

18381840

18391841
class page_:
18401842
page_id: str
1843+
silent_quit: bool
18411844

18421845
def __enter__(self):
18431846
self.page_id = random_str(10)
@@ -1850,29 +1853,27 @@ def __exit__(self, exc_type, exc_val, exc_tb):
18501853
so that the with statement terminates the propagation of the exception
18511854
"""
18521855
get_current_session().pop_page()
1853-
send_msg('close_page', dict(page_id=self.page_id))
1856+
if isinstance(exc_val, PageClosedException): # page is close by app user
1857+
if self.silent_quit:
1858+
# supress PageClosedException Exception
1859+
return True
1860+
else:
1861+
send_msg('close_page', dict(page_id=self.page_id))
18541862

1855-
# todo: catch Page Close Exception
1856-
return False # Propagate Exception
1863+
return False # propagate Exception
18571864

18581865
def __call__(self, func):
18591866
"""decorator implement"""
18601867

18611868
@wraps(func)
18621869
def wrapper(*args, **kwargs):
1863-
self.__enter__()
1864-
try:
1870+
with self:
18651871
return func(*args, **kwargs)
1866-
finally:
1867-
self.__exit__(None, None, None)
18681872

18691873
@wraps(func)
18701874
async def coro_wrapper(*args, **kwargs):
1871-
self.__enter__()
1872-
try:
1875+
with self:
18731876
return await func(*args, **kwargs)
1874-
finally:
1875-
self.__exit__(None, None, None)
18761877

18771878
if iscoroutinefunction(func):
18781879
return coro_wrapper

pywebio/session/base.py

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import user_agents
77

88
from ..utils import catch_exp_call
9+
from ..exceptions import PageClosedException
910

1011
logger = logging.getLogger(__name__)
1112

@@ -62,7 +63,8 @@ def __init__(self, session_info):
6263
self.internal_save = dict(info=session_info) # some session related info, just for internal used
6364
self.save = {} # underlying implement of `pywebio.session.data`
6465
self.scope_stack = defaultdict(lambda: ['ROOT']) # task_id -> scope栈
65-
self.page_stack = defaultdict(lambda: []) # task_id -> page id
66+
self.page_stack = defaultdict(lambda: []) # task_id -> page id stack
67+
self.active_page = defaultdict(set) # task_id -> activate page set
6668

6769
self.deferred_functions = [] # 会话结束时运行的函数
6870
self._closed = False
@@ -96,24 +98,49 @@ def push_scope(self, name):
9698
self.scope_stack[task_id].append(name)
9799

98100
def get_page_id(self):
101+
"""
102+
get the if of current page in task, return `None` when it's master page,
103+
raise PageClosedException when current page is closed
104+
"""
99105
task_id = type(self).get_current_task_id()
100-
if task_id not in self.page_stack:
101-
return None
102-
try:
103-
return self.page_stack[task_id][-1]
104-
except IndexError:
106+
if task_id not in self.page_stack or not self.page_stack[task_id]:
107+
# current in master page
105108
return None
106109

110+
page_id = self.page_stack[task_id][-1]
111+
if page_id not in self.active_page[task_id]:
112+
raise PageClosedException(
113+
"The page is closed by app user, "
114+
"you see this exception mostly because you set `silent_quit=False` in `pywebio.output.page()`"
115+
)
116+
117+
return page_id
118+
107119
def pop_page(self):
120+
"""exit the current page in task"""
108121
task_id = type(self).get_current_task_id()
109122
try:
110-
return self.page_stack[task_id].pop()
123+
page_id = self.page_stack[task_id].pop()
111124
except IndexError:
112125
raise ValueError("Internal Error: No page to exit") from None
113126

127+
try:
128+
self.active_page[task_id].remove(page_id)
129+
except KeyError:
130+
pass
131+
return page_id
132+
114133
def push_page(self, page_id):
115134
task_id = type(self).get_current_task_id()
116135
self.page_stack[task_id].append(page_id)
136+
self.active_page[task_id].add(page_id)
137+
138+
def notify_page_lost(self, task_id, page_id):
139+
"""update page status when there is page lost"""
140+
try:
141+
self.active_page[task_id].remove(page_id)
142+
except KeyError:
143+
pass
117144

118145
def send_task_command(self, command):
119146
raise NotImplementedError
@@ -123,7 +150,13 @@ def next_client_event(self) -> dict:
123150
raise NotImplementedError
124151

125152
def send_client_event(self, event):
126-
raise NotImplementedError
153+
"""send event from client to session,
154+
return True when this event is handled by this method, which means the subclass must ignore this event.
155+
"""
156+
if event['event'] == 'page_close':
157+
self.notify_page_lost(event['task_id'], event['data'])
158+
return True
159+
return False
127160

128161
def get_task_commands(self) -> list:
129162
raise NotImplementedError
@@ -185,6 +218,10 @@ def defer_call(self, func):
185218
self.deferred_functions.append(func)
186219

187220
def need_keep_alive(self) -> bool:
221+
"""
222+
return whether to need to hold this session if it runs over now.
223+
if the session maintains some event callbacks, it needs to hold session unit user close the session
224+
"""
188225
raise NotImplementedError
189226

190227

pywebio/session/coroutinebased.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ def send_client_event(self, event):
138138
139139
:param dict event: 事件️消息
140140
"""
141+
handled = super(CoroutineBasedSession, self).send_client_event(event)
142+
if handled:
143+
return
144+
141145
coro_id = event['task_id']
142146
coro = self.coros.get(coro_id)
143147
if not coro:

pywebio/session/threadbased.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,10 @@ def send_client_event(self, event):
146146
147147
:param dict event: 事件️消息
148148
"""
149+
handled = super(ThreadBasedSession, self).send_client_event(event)
150+
if handled:
151+
return
152+
149153
task_id = event['task_id']
150154
mq = self.task_mqs.get(task_id)
151155
if not mq and task_id in self.callbacks:

webiojs/src/handlers/page.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export class PageHandler implements CommandHandler {
1010

1111
handle_message(msg: Command) {
1212
if (msg.command === 'open_page') {
13-
OpenPage(msg.spec.page_id);
13+
OpenPage(msg.spec.page_id, msg.task_id);
1414
} else if (msg.command === 'close_page') {
1515
ClosePage(msg.spec.page_id);
1616
}

webiojs/src/models/page.ts

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,81 @@
11
// page id to page window reference
22
import {Command, SubPageSession} from "../session";
33
import {LazyPromise} from "../utils";
4+
import {state} from "../state";
45

5-
let subpages: { [page_id: string]: Window } = {};
6+
let subpages: {
7+
[page_id: string]: {
8+
page: Window,
9+
task_id: string
10+
}
11+
} = {};
612

13+
function start_clean_up_task() {
14+
return setInterval(() => {
15+
let page;
16+
for (let page_id in subpages) {
17+
page = subpages[page_id].page;
18+
if (page.closed || !SubPageSession.is_sub_page(page)) {
19+
on_page_lost(page_id);
20+
}
21+
}
22+
}, 1000)
23+
}
24+
25+
// page is closed accidentally
26+
function on_page_lost(page_id: string) {
27+
console.log(`page ${page_id} exit`);
28+
if (!(page_id in subpages)) // it's a duplicated call
29+
return;
30+
31+
let task_id = subpages[page_id].task_id;
32+
delete subpages[page_id];
33+
state.CurrentSession.send_message({
34+
event: "page_close",
35+
task_id: task_id,
36+
data: page_id
37+
});
38+
}
739

8-
export function OpenPage(page_id: string) {
40+
let clean_up_task_id: number = null;
41+
42+
export function OpenPage(page_id: string, task_id: string) {
943
if (page_id in subpages)
1044
throw `Can't open page, the page id "${page_id}" is duplicated`;
11-
subpages[page_id] = window.open(window.location.href);
45+
46+
if (!clean_up_task_id)
47+
clean_up_task_id = start_clean_up_task()
48+
49+
let page = window.open(window.location.href);
50+
subpages[page_id] = {page: page, task_id: task_id}
1251

1352
// the `_pywebio_page` will be resolved in new opened page in `SubPageSession.start_session()`
1453
// @ts-ignore
15-
subpages[page_id]._pywebio_page = new LazyPromise()
54+
page._pywebio_page = new LazyPromise()
55+
56+
// this event is not reliably fired by browsers
57+
// https://developer.mozilla.org/en-US/docs/Web/API/Window/pagehide_event#usage_notes
58+
page.addEventListener('pagehide', event => {
59+
// wait some time to for `page.closed`
60+
setTimeout(() => {
61+
if (page.closed || !SubPageSession.is_sub_page(page))
62+
on_page_lost(page_id)
63+
}, 100)
64+
});
1665
}
1766

1867
export function ClosePage(page_id: string) {
1968
if (!(page_id in subpages))
2069
throw `Can't close page, the page (id "${page_id}") is not found`;
21-
subpages[page_id].close()
70+
subpages[page_id].page.close();
71+
delete subpages[page_id];
2272
}
2373

2474
export function DeliverMessage(msg: Command) {
2575
if (!(msg.page in subpages))
2676
throw `Can't deliver message, the page (id "${msg.page}") is not found`;
2777
// @ts-ignore
28-
subpages[msg.page]._pywebio_page.promise.then((page: SubPageSession) => {
78+
subpages[msg.page].page._pywebio_page.promise.then((page: SubPageSession) => {
2979
msg.page = undefined;
3080
page.server_message(msg);
3181
});

webiojs/src/session.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,17 @@ export class SubPageSession implements Session {
6565
private _on_server_message: (msg: Command) => any = () => {
6666
};
6767

68-
// check if it's a pywebio subpage
69-
static is_sub_page(): boolean {
68+
// check if the window is a pywebio subpage
69+
static is_sub_page(window_obj: Window = window): boolean {
7070
// - `window._pywebio_page` lazy promise is not undefined
7171
// - window.opener is not null and window.opener.WebIO is not undefined
72-
// @ts-ignore
73-
return window._pywebio_page !== undefined && window.opener !== null && window.opener.WebIO !== undefined;
72+
73+
try {
74+
// @ts-ignore
75+
return window_obj._pywebio_page !== undefined && window_obj.opener !== null && window_obj.opener.WebIO !== undefined;
76+
}catch (e) {
77+
return false;
78+
}
7479
}
7580

7681
on_session_create(callback: () => any): void {

0 commit comments

Comments
 (0)