Skip to content

Commit 1e29870

Browse files
committed
Finish progress reporting. Split wm_server module in multiple modules.
1 parent 2b28745 commit 1e29870

17 files changed

Lines changed: 2089 additions & 1034 deletions

File tree

finecode_builtin_handlers/src/finecode_builtin_handlers/lint.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,15 @@ async def run(
7474
lint_files_action_instance = self.action_runner.get_action_by_source(
7575
lint_files_action.LintFilesAction
7676
)
77-
async for partial in self.action_runner.run_action_iter(
78-
action=lint_files_action_instance,
79-
payload=lint_files_action.LintFilesRunPayload(file_paths=file_uris),
80-
meta=run_meta,
81-
):
82-
yield lint_action.LintRunResult(messages=partial.messages)
77+
async with run_context.progress("Linting files", total=len(file_uris)) as progress:
78+
async for partial in self.action_runner.run_action_iter(
79+
action=lint_files_action_instance,
80+
payload=lint_files_action.LintFilesRunPayload(file_paths=file_uris),
81+
meta=run_meta,
82+
):
83+
uris = list(partial.messages)
84+
msg = str(uris[0]) if uris else None
85+
if len(uris) > 1:
86+
msg += f" and {len(uris) - 1} related"
87+
await progress.advance(message=msg)
88+
yield lint_action.LintRunResult(messages=partial.messages)

finecode_extension_api/src/finecode_extension_api/code_action.py

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,109 @@ class PartialResultSender(typing.Protocol):
9494
async def send(self, result: RunActionResult) -> None: ...
9595

9696

97-
class _NoOpPartialResultSender:
97+
class _NoOpPartialResultSender(PartialResultSender):
9898
async def send(self, result: RunActionResult) -> None:
9999
pass
100100

101101

102102
_NOOP_SENDER = _NoOpPartialResultSender()
103103

104104

105+
class ProgressSender(typing.Protocol):
106+
"""Framework-internal interface for sending progress notifications."""
107+
108+
async def begin(
109+
self,
110+
title: str,
111+
message: str | None = None,
112+
percentage: int | None = None,
113+
cancellable: bool = False,
114+
total: int | None = None,
115+
) -> None: ...
116+
117+
async def report(
118+
self,
119+
message: str | None = None,
120+
percentage: int | None = None,
121+
) -> None: ...
122+
123+
async def end(self, message: str | None = None) -> None: ...
124+
125+
126+
class _NoOpProgressSender(ProgressSender):
127+
async def begin(
128+
self,
129+
title: str,
130+
message: str | None = None,
131+
percentage: int | None = None,
132+
cancellable: bool = False,
133+
total: int | None = None,
134+
) -> None:
135+
pass
136+
137+
async def report(
138+
self,
139+
message: str | None = None,
140+
percentage: int | None = None,
141+
) -> None:
142+
pass
143+
144+
async def end(self, message: str | None = None) -> None:
145+
pass
146+
147+
148+
_NOOP_PROGRESS_SENDER = _NoOpProgressSender()
149+
150+
151+
class ProgressContext:
152+
"""Async context manager for reporting progress from handlers.
153+
154+
Two methods serve different use cases:
155+
- ``advance(steps, message)`` — the common "N of M" pattern; auto-calculates percentage.
156+
- ``report(message, percentage)`` — freeform; for indeterminate progress or custom logic.
157+
"""
158+
159+
def __init__(
160+
self,
161+
sender: ProgressSender,
162+
title: str,
163+
*,
164+
total: int | None = None,
165+
cancellable: bool = False,
166+
) -> None:
167+
self._sender = sender
168+
self._title = title
169+
self._total = total
170+
self._cancellable = cancellable
171+
self._completed = 0
172+
173+
async def __aenter__(self) -> ProgressContext:
174+
percentage = 0 if self._total is not None else None
175+
await self._sender.begin(
176+
self._title,
177+
percentage=percentage,
178+
cancellable=self._cancellable,
179+
total=self._total,
180+
)
181+
return self
182+
183+
async def __aexit__(self, *exc) -> bool:
184+
await self._sender.end()
185+
return False
186+
187+
async def advance(self, steps: int = 1, message: str | None = None) -> None:
188+
"""Step-based progress. Auto-calculates percentage from total."""
189+
self._completed += steps
190+
percentage = None
191+
if self._total is not None:
192+
percentage = min(int(self._completed / self._total * 100), 100)
193+
await self._sender.report(message=message, percentage=percentage)
194+
195+
async def report(self, message: str | None = None, percentage: int | None = None) -> None:
196+
"""Freeform progress. Caller controls the percentage directly."""
197+
await self._sender.report(message=message, percentage=percentage)
198+
199+
105200
class RunActionContext(typing.Generic[RunPayloadType]):
106201
# data object to save data between action steps(only during one run, after run data
107202
# is removed). Keep it simple, without business logic, just data storage, but you
@@ -116,13 +211,27 @@ def __init__(
116211
meta: RunActionMeta,
117212
info_provider: RunContextInfoProvider,
118213
partial_result_sender: PartialResultSender = _NOOP_SENDER,
214+
progress_sender: ProgressSender = _NOOP_PROGRESS_SENDER,
119215
) -> None:
120216
self.run_id = run_id
121217
self.initial_payload = initial_payload
122218
self.meta = meta
123219
self.exit_stack = contextlib.AsyncExitStack()
124220
self._info_provider = info_provider
125221
self.partial_result_sender = partial_result_sender
222+
self._progress_sender = progress_sender
223+
224+
def progress(
225+
self,
226+
title: str,
227+
*,
228+
total: int | None = None,
229+
cancellable: bool = False,
230+
) -> ProgressContext:
231+
"""Create a progress context manager for reporting progress to the client."""
232+
return ProgressContext(
233+
self._progress_sender, title, total=total, cancellable=cancellable
234+
)
126235

127236
@property
128237
def current_result(self) -> RunActionResult | None:
@@ -165,13 +274,15 @@ def __init__(
165274
meta: RunActionMeta,
166275
info_provider: RunContextInfoProvider,
167276
partial_result_sender: PartialResultSender = _NOOP_SENDER,
277+
progress_sender: ProgressSender = _NOOP_PROGRESS_SENDER,
168278
) -> None:
169279
super().__init__(
170280
run_id=run_id,
171281
initial_payload=initial_payload,
172282
meta=meta,
173283
info_provider=info_provider,
174284
partial_result_sender=partial_result_sender,
285+
progress_sender=progress_sender,
175286
)
176287
self.partial_result_scheduler = partialresultscheduler.PartialResultScheduler()
177288

finecode_extension_runner/src/finecode_extension_runner/_services/run_action.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,60 @@ def set_partial_result_sender(send_func: typing.Callable) -> None:
6666
)
6767

6868

69+
progress_sender_func: typing.Callable | None = None
70+
71+
72+
def set_progress_sender(send_func: typing.Callable) -> None:
73+
global progress_sender_func
74+
progress_sender_func = send_func
75+
76+
77+
class _ERProgressSender:
78+
"""Concrete ProgressSender that sends notifications via the ER LSP server."""
79+
80+
def __init__(
81+
self,
82+
token: int | str,
83+
send_func: typing.Callable[[int | str, dict], None],
84+
) -> None:
85+
self._token = token
86+
self._send_func = send_func
87+
88+
async def begin(
89+
self,
90+
title: str,
91+
message: str | None = None,
92+
percentage: int | None = None,
93+
cancellable: bool = False,
94+
total: int | None = None,
95+
) -> None:
96+
self._send_func(self._token, {
97+
"type": "begin",
98+
"title": title,
99+
"message": message,
100+
"percentage": percentage,
101+
"cancellable": cancellable,
102+
"total": total,
103+
})
104+
105+
async def report(
106+
self,
107+
message: str | None = None,
108+
percentage: int | None = None,
109+
) -> None:
110+
self._send_func(self._token, {
111+
"type": "report",
112+
"message": message,
113+
"percentage": percentage,
114+
})
115+
116+
async def end(self, message: str | None = None) -> None:
117+
self._send_func(self._token, {
118+
"type": "end",
119+
"message": message,
120+
})
121+
122+
69123
class AsyncPlaceholderContext:
70124
async def __aenter__(self):
71125
return self
@@ -78,6 +132,7 @@ async def run_action(
78132
payload: code_action.RunActionPayload | None,
79133
meta: code_action.RunActionMeta,
80134
partial_result_token: int | str | None = None,
135+
progress_token: int | str | None = None,
81136
run_id: int | None = None,
82137
partial_result_queue: asyncio.Queue | None = None,
83138
) -> code_action.RunActionResult | None:
@@ -132,6 +187,15 @@ async def run_action(
132187
)
133188
else:
134189
tracking_sender = None
190+
191+
if progress_token is not None and progress_sender_func is not None:
192+
er_progress_sender: code_action.ProgressSender = _ERProgressSender(
193+
token=progress_token,
194+
send_func=progress_sender_func,
195+
)
196+
else:
197+
er_progress_sender = code_action._NOOP_PROGRESS_SENDER
198+
135199
if action_exec_info.run_context_type is not None:
136200
constructor_args = await resolve_func_args_with_di(
137201
action_exec_info.run_context_type.__init__,
@@ -141,6 +205,7 @@ async def run_action(
141205
"meta": lambda _: meta,
142206
"info_provider": lambda _: run_context_info,
143207
"partial_result_sender": lambda _: tracking_sender or code_action._NOOP_SENDER,
208+
"progress_sender": lambda _: er_progress_sender,
144209
},
145210
params_to_ignore=["self"],
146211
)
@@ -414,6 +479,7 @@ async def run_action_raw(
414479
payload=payload,
415480
meta=options.meta,
416481
partial_result_token=options.partial_result_token,
482+
progress_token=options.progress_token,
417483
run_id=run_id,
418484
)
419485

finecode_extension_runner/src/finecode_extension_runner/lsp_server.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,13 @@ async def lsp_connection(
172172
)
173173
logger.debug("Main loop finished")
174174
self.shutdown()
175+
# Close the writer so the transport is removed from the server's
176+
# active-client set (_clients). Without this, eof_received() returns
177+
# True (half-close) and the transport lingers, causing wait_closed()
178+
# inside serve_forever() to block indefinitely — the ghost-process
179+
# scenario when the IDE closes without a clean LSP shutdown.
180+
writer.close()
181+
self._server.close()
175182

176183
async def tcp_server(h: str, p: int):
177184
self._server = await asyncio.start_server(lsp_connection, h, p)
@@ -212,6 +219,13 @@ async def lsp_connection(
212219
)
213220
logger.debug("Main loop finished")
214221
self.shutdown()
222+
# Close the writer so the transport is removed from the server's
223+
# active-client set (_clients). Without this, eof_received() returns
224+
# True (half-close) and the transport lingers, causing wait_closed()
225+
# inside serve_forever() to block indefinitely — the ghost-process
226+
# scenario when the IDE closes without a clean LSP shutdown/exit.
227+
writer.close()
228+
self._server.close()
215229

216230
self._server = await asyncio.start_server(lsp_connection, host, port)
217231

@@ -221,6 +235,8 @@ async def lsp_connection(
221235
try:
222236
async with self._server:
223237
await self._server.serve_forever()
238+
except asyncio.CancelledError:
239+
logger.debug("TCP server closed after client disconnect")
224240
finally:
225241
await self._finecode_exit_stack.aclose()
226242

@@ -297,6 +313,9 @@ def create_lsp_server() -> lsp_server.LanguageServer:
297313
register_get_payload_schemas_cmd = server.command("actions/getPayloadSchemas")
298314
register_get_payload_schemas_cmd(get_payload_schemas_cmd)
299315

316+
register_get_runner_info_cmd = server.command("finecodeRunner/getInfo")
317+
register_get_runner_info_cmd(get_runner_info)
318+
300319
def on_process_exit():
301320
logger.info("Exit extension runner")
302321
services.shutdown_all_action_handlers()
@@ -315,6 +334,15 @@ def send_partial_result(
315334
server.progress(types.ProgressParams(token=token, value=partial_result_json))
316335

317336
run_action_service.set_partial_result_sender(send_partial_result)
337+
338+
def send_progress(token: int | str, value_dict: dict) -> None:
339+
value_json = json.dumps(value_dict)
340+
logger.trace(
341+
f"send_progress: token={token}, type={value_dict.get('type')}, preview={value_json[:200]}"
342+
)
343+
server.progress(types.ProgressParams(token=token, value=value_json))
344+
345+
run_action_service.set_progress_sender(send_progress)
318346

319347
return server
320348

@@ -580,3 +608,9 @@ async def merge_results_cmd(ls: lsp_server.LanguageServer, action_name: str, res
580608
except Exception as exception:
581609
logger.exception(f"Merge results error: {exception}")
582610
return {"error": str(exception)}
611+
612+
613+
async def get_runner_info(ls: lsp_server.LanguageServer):
614+
from finecode_extension_runner import global_state
615+
log_path = global_state.log_file_path
616+
return {"logFilePath": str(log_path) if log_path is not None else None}

0 commit comments

Comments
 (0)