-
Notifications
You must be signed in to change notification settings - Fork 54
Expand file tree
/
Copy patheca-process.el
More file actions
796 lines (729 loc) · 36.7 KB
/
Copy patheca-process.el
File metadata and controls
796 lines (729 loc) · 36.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
;;; eca-process.el --- ECA (Editor Code Assistant) process -*- lexical-binding: t; -*-
;; Copyright (C) 2025 Eric Dallo
;;
;; SPDX-License-Identifier: Apache-2.0
;;
;; This file is not part of GNU Emacs.
;;
;;; Commentary:
;;
;; The ECA (Editor Code Assistant) process.
;;
;;; Code:
(require 's)
(require 'f)
(require 'eca-util)
(require 'eca-api)
(defcustom eca-custom-command nil
"The eca server command.
If not provided, download and start eca automatically."
:group 'eca
:risky t
:type '(repeat string))
(defcustom eca-extra-args '()
"The extra args to pass to eca server command."
:group 'eca
:risky t
:type '(repeat string))
(defcustom eca-process-wrapper-function nil
"Optional function to wrap the eca server command before launch.
When non-nil, called with two arguments: COMMAND (list of strings,
already including `eca-extra-args') and ROOTS (list of absolute
workspace folder paths). Must return a list of strings to be
passed to `make-process'.
Useful for running ECA inside a sandbox like firejail, bubblewrap,
or jai, optionally whitelisting workspace roots at startup. See
the README's \"Sandboxing\" section for examples."
:group 'eca
:risky t
:type '(choice (const nil) function))
(defcustom eca-server-download-method 'url-retrieve
"The method to use to download eca server binary.
Some Emacs versions / distributions have issues with
curl blocking Emacs, but `url-retrieve' should be always async.
Test different options if facing issues."
:group 'eca
:type '(choice
(const :tag "Async url-retrieve" url-retrieve)
(const :tag "Curl" curl)))
(defcustom eca-server-download-url nil
"The custom URL to download eca server."
:group 'eca
:type 'string)
(defcustom eca-server-fetch-timeout 20
"Seconds before a GitHub fetch is considered stuck.
Used as curl `--max-time' when fetching the releases list and as
curl `--connect-timeout' when downloading the server binary, so an
unreachable GitHub (e.g. an outage) cannot block Emacs for long."
:group 'eca
:type 'integer)
(defcustom eca-server-fetch-retries 2
"How many times curl retries transient GitHub failures.
Applied via curl `--retry' to the releases list fetch and to server
binary downloads when `eca-server-download-method' is `curl'. When
all attempts fail, eca falls back to the already installed server
binary if available."
:group 'eca
:type 'integer)
(defcustom eca-server-install-path
(f-join (expand-file-name
(locate-user-emacs-file "eca"))
(if (eq system-type 'windows-nt)
"eca.exe"
"eca"))
"Path to the eca server binary."
:risky t
:type 'file
:group 'eca)
(defcustom eca-server-version-file-path
(f-join (expand-file-name
(locate-user-emacs-file "eca"))
"eca-version")
"File in which eca version will be."
:risky t
:type 'string
:group 'eca)
(defconst eca-ext-pwsh-script "pwsh -noprofile -noninteractive \
-nologo -ex bypass -c Expand-Archive -Path '%s' -DestinationPath '%s'"
"Pwsh script to unzip file.")
(defconst eca-ext-powershell-script "powershell -noprofile -noninteractive \
-nologo -ex bypass -command Expand-Archive -path '%s' -dest '%s'"
"Powershell script to unzip file.")
(defconst eca-ext-unzip-script "bash -c 'mkdir -p %2$s && unzip -qq -o %1$s -d %2$s'"
"Unzip script to unzip file.")
(defconst eca-ext-ark-script "bash -c 'mkdir -p %2$s && ark -b -o %2$s %1$s'"
"Ark script to unzip file.")
(defcustom eca-unzip-script (lambda ()
(cond ((and (eq system-type 'windows-nt)
(executable-find "pwsh"))
eca-ext-pwsh-script)
((and (eq system-type 'windows-nt)
(executable-find "powershell"))
eca-ext-powershell-script)
((executable-find "unzip") eca-ext-unzip-script)
((executable-find "pwsh") eca-ext-pwsh-script)
((executable-find "ark") eca-ext-ark-script)
(t nil)))
"The script to unzip downloaded eca server."
:group 'eca
:type 'string)
(defcustom eca-min-gc-cons-threshold (* 100 1024 1024)
"Temporarily increase GC threshold during heavy message processing.
If current `gc-cons-threshold` is lower use that on filter server messages.'"
:type 'integer
:group 'eca)
(defun eca-process--buffer-name (session)
"Return the process buffer name for SESSION."
(format "<eca[%s]:%s>"
(eca--session-project-name session)
(eca--session-id session)))
(defun eca-process--stderr-buffer-name (session)
"Return the stderr buffer name for SESSION."
(format "<eca:stderr[%s]:%s>"
(eca--session-project-name session)
(eca--session-id session)))
(defcustom eca-server-releases-cache-ttl 3600
"Time-to-live (seconds) for the cached eca server releases list.
Once expired, the next call that needs the releases list will refetch
from the GitHub API. Set to nil to disable expiry (legacy behavior:
cache lives until Emacs is restarted). Set to 0 to always refetch.
This affects update detection at session start and on `eca-restart',
so longer-running Emacs sessions can still pick up newer eca releases.
See also `eca-server-check-updates'."
:type '(choice (const :tag "Never expire" nil)
(integer :tag "Seconds"))
:group 'eca)
(defvar eca-process--releases-cache nil
"Cached parsed releases list from GitHub API.
When non-nil, a cons cell of (FETCH-TIME . RELEASES) where FETCH-TIME
is the value of `float-time' when RELEASES was last fetched. Honored
together with `eca-server-releases-cache-ttl' by
`eca-process--fetch-releases'.")
(defconst eca-process--releases-failure-cooldown 60
"Seconds to wait before retrying a failed releases fetch.")
(defvar eca-process--releases-fetch-failed-at nil
"Value of `float-time' when the last releases fetch failed, or nil.
While within `eca-process--releases-failure-cooldown' seconds of it,
`eca-process--fetch-releases' skips the network and returns any stale
cached value, so repeated eca starts during a GitHub outage stay
fast.")
(cl-defun eca--curl-download-file (&key url path on-done on-error)
"Downloads a file from URL to PATH shelling out to system with curl.
Retries transient failures `eca-server-fetch-retries' times, bounding
the connection phase by `eca-server-fetch-timeout' seconds.
Calls ON-DONE when done. On failure calls ON-ERROR with the error
when provided, otherwise signals it."
(condition-case err
(let ((curl-cmd (or (executable-find "curl")
(executable-find "curl.exe"))))
(unless curl-cmd
(error "Curl not found. Please install curl or customize eca-custom-command"))
(let ((exit-code (shell-command (format "%s -L -s -S -f --connect-timeout %d --retry %d -o %s %s"
(shell-quote-argument curl-cmd)
eca-server-fetch-timeout
eca-server-fetch-retries
(shell-quote-argument path)
(shell-quote-argument url)))))
(unless (= exit-code 0)
(error "Curl failed with exit code %d" exit-code)))
(funcall on-done))
(error (if on-error
(funcall on-error err)
(signal (car err) (cdr err))))))
(cl-defun eca--url-retrieve-download-file (&key url path on-done on-error)
"Downloads async a file from URL to PATH via `url-retrieve'.
Calls ON-DONE when done. On failure calls ON-ERROR with the error
when provided, otherwise signals it.
Workaround for `url-copy-file` that has issues with macos async threads.
https://github.com/emacs-lsp/lsp-mode/issues/4746#issuecomment-2957183423"
(url-retrieve
url
(lambda (status)
(let ((resp-buf (current-buffer)))
(unwind-protect
(condition-case err
(progn
(when-let ((error-data (plist-get status :error)))
(error "%s" error-data))
(let ((coding-system-for-write 'binary)
(buffer-file-coding-system 'binary))
(goto-char (point-min))
(unless (re-search-forward "\r?\n\r?\n" nil t)
(error "Failed to parse HTTP response for download"))
(write-region (point) (point-max) path nil 'silent)
(funcall on-done)))
(error (if on-error
(funcall on-error err)
(signal (car err) (cdr err)))))
(ignore-errors (kill-buffer resp-buf)))))
nil
t))
(defun eca--curl-download-string (url)
"Download content from URL as a string, shelling out to curl.
Bounded by `eca-server-fetch-timeout' seconds and retried up to
`eca-server-fetch-retries' times so an unreachable GitHub cannot
hang Emacs. Signals an error when the download fails."
(let ((curl-cmd (or (executable-find "curl")
(executable-find "curl.exe"))))
(unless curl-cmd
(error "Curl not found. Please install curl or customize eca-custom-command"))
(with-temp-buffer
(let ((exit-code (call-process curl-cmd nil (list (current-buffer) nil) nil
"-L" "-s" "-f"
"--max-time" (number-to-string eca-server-fetch-timeout)
"--retry" (number-to-string eca-server-fetch-retries)
url)))
(unless (and (numberp exit-code) (zerop exit-code))
(error "Curl failed to download from %s (exit %s)" url exit-code))
(when (zerop (buffer-size))
(error "Curl returned an empty response from %s" url))
(buffer-string)))))
(defconst eca-process--releases-url "https://api.github.com/repos/editor-code-assistant/eca/releases"
"Github url for retrieving json files with infos about release binaries.")
(defun eca-process--releases-cache-valid-p ()
"Return non-nil if `eca-process--releases-cache' is fresh enough.
Honors `eca-server-releases-cache-ttl': when nil any cached entry is
considered valid; when 0 the cache is always considered stale."
(when (consp eca-process--releases-cache)
(let ((ttl eca-server-releases-cache-ttl)
(fetched-at (car eca-process--releases-cache)))
(cond
((null ttl) t)
((and (numberp ttl) (<= ttl 0)) nil)
((numberp fetched-at)
(< (- (float-time) fetched-at) ttl))
(t nil)))))
(defun eca-process--fetch-releases ()
"Return cached releases list, fetching from GitHub if needed.
Refetches when the cache is empty or has expired per
`eca-server-releases-cache-ttl'. On fetch failure, any previously
cached value is preserved and returned, and further fetch attempts
are skipped for `eca-process--releases-failure-cooldown' seconds."
(cond
((eca-process--releases-cache-valid-p)
(cdr eca-process--releases-cache))
((and eca-process--releases-fetch-failed-at
(< (- (float-time) eca-process--releases-fetch-failed-at)
eca-process--releases-failure-cooldown))
(cdr-safe eca-process--releases-cache))
(t
(condition-case err
(let* ((coding-system-for-read 'utf-8)
(json-string
(eca--curl-download-string
eca-process--releases-url))
(releases (with-temp-buffer
(insert json-string)
(goto-char (point-min))
(eca-api--json-read-buffer))))
;; Guard against non-release payloads (e.g. a rate-limit
;; JSON object) poisoning the cache.
(unless (and (or (vectorp releases) (proper-list-p releases))
(> (length releases) 0)
(plist-get (elt releases 0) :tag_name))
(error "Unexpected releases payload"))
(setq eca-process--releases-fetch-failed-at nil)
(setq eca-process--releases-cache
(cons (float-time) releases))
releases)
(error
(setq eca-process--releases-fetch-failed-at (float-time))
(eca-warn "Failed to fetch releases: %s" err)
(cdr-safe eca-process--releases-cache))))))
(defun eca-process--get-latest-server-version ()
"Return the latest server version."
(when-let ((releases (eca-process--fetch-releases)))
(plist-get (elt releases 0) :tag_name)))
(defun eca-process--get-property (property &optional version)
"Retrieve PROPERTY for server binary VERSION.
When VERSION is nil, returns PROPERTY from the latest release."
(when-let ((releases (eca-process--fetch-releases)))
(let ((props (if version
(seq-find (lambda (ver)
(string-equal
(plist-get ver :tag_name)
version))
releases)
(elt releases 0))))
(plist-get props property))))
(defun eca-process--get-current-server-version ()
"Return the current version of installed server if available."
(when (f-exists? eca-server-version-file-path)
(f-read eca-server-version-file-path)))
(defun eca-process--find-extracted-binary (temp-dir name)
"Find extracted binary NAME in TEMP-DIR.
On Windows, handle .exe extension mismatch:
try alternate name with or without .exe."
(let ((primary (f-join temp-dir name)))
(if (f-exists? primary)
primary
(when (eq system-type 'windows-nt)
(let ((alt (if (string-suffix-p ".exe" name)
(f-join temp-dir
(file-name-sans-extension name))
(f-join temp-dir
(concat name ".exe")))))
(when (f-exists? alt) alt))))))
(defun eca-process--download-and-store-path ()
"Return the path of the download and store."
(let* ((store-path eca-server-install-path)
(download-path (concat store-path ".zip")))
`(,download-path . ,store-path)))
(defun eca-process--cleanup-old-server ()
"Try to delete any leftover .old server binary from previous update.
On Windows, running executables can be renamed but not deleted, so we
clean them up on next startup."
(let ((old-path (concat eca-server-install-path ".old")))
(when (f-exists? old-path)
(condition-case nil
(progn
(f-delete old-path)
(eca-info "Cleaned up old server binary"))
(error nil)))))
(defun eca-process--uninstall-server ()
"Remove downloaded server."
(-let (((download-path . store-path) (eca-process--download-and-store-path)))
(when (f-exists? download-path) (f-delete download-path))
(when (f-exists? store-path) (f-delete store-path))
(when (f-exists? eca-server-version-file-path) (f-delete eca-server-version-file-path))))
(defun eca-process--download-url (version)
"Return the server download url for VERSION."
(or eca-server-download-url
(format "https://github.com/editor-code-assistant/eca/releases/download/%s/eca-native-%s.zip"
version
(let ((arch (car (split-string system-configuration "-"))))
(pcase system-type
('gnu/linux (cond
((string= "x86_64" arch) "static-linux-amd64")
(t (concat "linux-" arch))))
('darwin (concat "macos-"
(cond
((string= "x86_64" arch) "amd64")
(t arch))))
('windows-nt "windows-amd64"))))))
(defun eca-process--get-file-sha256 (file)
"Compute and return the SHA256 hash of FILE."
(with-temp-buffer
(set-buffer-multibyte nil)
(insert-file-contents-literally file)
(secure-hash 'sha256 (current-buffer))))
(defun eca-process--check-sha256 (download-path url version)
"Check sha256 checksum of archive at DOWNLOAD-PATH.
The archive should be retrieved from URL and have
the given VERSION."
(if-let* ((asset (seq-find
(lambda (asset)
(let ((asset-url (plist-get asset :browser_download_url)))
(and (stringp asset-url)
(string-equal url asset-url))))
(eca-process--get-property :assets version)))
(digest (plist-get asset :digest))
(sha256 (and (stringp digest)
(string-match "sha256:" digest)
(substring digest (match-end 0)))))
(unless (string-equal
sha256
(eca-process--get-file-sha256 download-path))
(error "The downloaded archive for the eca binary is corrupted"))
(eca-warn "Cannot retrieve sha256 for the eca binary archive, skipping checksum verification")))
(defun eca-process--download-server (on-downloaded version &optional on-error)
"Download eca server of VERSION calling ON-DOWNLOADED when success.
On failure calls ON-ERROR with the error when provided (at most
once, even for errors in async download callbacks), otherwise just
reports the error."
(-let ((url (eca-process--download-url version))
((download-path . store-path) (eca-process--download-and-store-path))
(old-path (concat eca-server-install-path ".old"))
(temp-extract-dir (concat (f-parent eca-server-install-path) "-temp"))
(download-fn (pcase eca-server-download-method
('url-retrieve #'eca--url-retrieve-download-file)
('curl #'eca--curl-download-file)
(_ (error (eca-error (format "Unknown download method '%s' for eca-server-download-method" eca-server-download-method))))))
(failed nil)
(fail-fn nil))
(setq fail-fn (lambda (err)
(unless failed
(setq failed t)
(if on-error
(funcall on-error err)
(eca-error "Failed to download eca server %s" err)))))
(condition-case err
(progn
;; Clean up any old files from previous updates
(eca-process--cleanup-old-server)
(when (f-exists? download-path) (f-delete download-path))
(when (f-exists? temp-extract-dir) (f-delete temp-extract-dir t))
(mkdir (f-parent download-path) t)
(eca-info "Downloading eca server from %s to %s..." url download-path)
(funcall
download-fn
:url url
:path download-path
:on-error fail-fn
:on-done (lambda ()
(condition-case err
(progn
(eca-info "Downloaded eca. Checking sha256...")
(eca-process--check-sha256 download-path url version)
(eca-info "Unzipping eca...")
(unless (and eca-unzip-script (funcall eca-unzip-script))
(error "Unable to find `unzip' or `powershell' on the path, please customize `eca-unzip-script'"))
;; Extract to temp directory first
(mkdir temp-extract-dir t)
(shell-command (format (funcall eca-unzip-script) download-path temp-extract-dir))
(let ((new-binary (eca-process--find-extracted-binary
temp-extract-dir (f-filename store-path))))
(unless new-binary
(error "Expected binary not found after extraction: %s"
(f-join temp-extract-dir (f-filename store-path))))
;; Rename old binary to .old if it exists
;; On Windows, running executables can be renamed but not deleted
(when (f-exists? store-path)
(when (f-exists? old-path)
(condition-case nil (f-delete old-path) (error nil)))
(rename-file store-path old-path))
;; Move new binary into place
(rename-file new-binary store-path))
;; Clean up temp directory
(when (f-exists? temp-extract-dir)
(condition-case nil (f-delete temp-extract-dir t) (error nil)))
;; Try to delete old binary (may fail if still in use, that's ok)
(when (f-exists? old-path)
(condition-case nil (f-delete old-path) (error nil)))
(f-write-text version 'utf-8 eca-server-version-file-path)
(set-file-modes store-path #o0700)
(eca-info "Installed eca successfully!")
(funcall on-downloaded))
(error (funcall fail-fn err))))))
(error (funcall fail-fn err)))))
(defun eca-process--server-command ()
"Return the command to start server."
(let ((system-command (executable-find "eca" (file-remote-p default-directory))))
(cond
(eca-custom-command (list :decision 'custom
:command eca-custom-command))
(system-command
(list :decision 'system
:command (list system-command "server")))
((file-remote-p default-directory)
(list :decision 'error-download
:message "ECA not found on remote host. Install `eca` on the remote PATH or set `eca-custom-command` to the remote binary path. ECA does not auto-install over TRAMP."))
((and (not (f-exists? eca-server-install-path))
(not (eca-process--get-latest-server-version)))
(list :decision 'error-download
:message "Could not fetch latest version of eca. Please check your internet connection and try again. You can also download eca manually and set the path via eca-custom-command variable"))
((f-exists? eca-server-install-path)
(let ((current (eca-process--get-current-server-version))
(latest (eca-process--get-latest-server-version)))
(if (or (null latest) ;; cannot check for updates, use installed binary.
(and current (not (string-version-lessp current latest))))
(list :decision 'already-installed
:command (list eca-server-install-path "server"))
;; Outdated, or version file missing (e.g. interrupted
;; update): reinstall to get back to a known version.
(list :decision 'download
:latest-version latest
:command (list eca-server-install-path "server")))))
(t (list :decision 'download
:latest-version (eca-process--get-latest-server-version)
:command (list eca-server-install-path "server"))))))
(defun eca-process--parse-header (s)
"Parse string S as a ECA (KEY . VAL) header."
(let ((pos (string-match "\:" s))
key val)
(unless pos
(signal 'eca-invalid-header-name (list s)))
(setq key (substring s 0 pos)
val (s-trim-left (substring s (+ 1 pos))))
(when (equal key "Content-Length")
(cl-assert (cl-loop for c across val
when (or (> c ?9) (< c ?0)) return nil
finally return t)
nil (format "Invalid Content-Length value: %s" val)))
(cons key val)))
(defun eca-process--make-filter (handle-msg)
"Return a process filter function that parse JSON-RPC from stdout.
HANDLE-MSG is called for each complete message parsed.
The returned closure captures parsing state across invocations so that
messages larger than a single OS pipe read (e.g. large `write_file'
tool calls with full diffs) are reassembled correctly."
(let (;; Persistent state across filter invocations:
(leftovers nil)
(body-length nil)
(body-received 0)
(body nil))
(lambda (_proc raw-output)
(let ((gc-cons-threshold (max gc-cons-threshold eca-min-gc-cons-threshold))
chunk)
(setf chunk (if (s-blank? leftovers)
(encode-coding-string raw-output 'utf-8-unix t)
(concat leftovers (encode-coding-string raw-output 'utf-8-unix t))))
(setf leftovers nil)
(let (messages)
(while (not (s-blank? chunk))
(if (not body-length)
;; Read headers
(if-let* ((body-sep-pos (string-match-p "\r\n\r\n" chunk)))
;; We've got all the headers, handle them all at once:
(setf body-length (let* ((headers (mapcar #'eca-process--parse-header
(split-string
(substring-no-properties chunk
(or (string-match-p "Content-Length" chunk)
(error "Unable to find Content-Length header"))
body-sep-pos)
"\r\n")))
(content-length (cdr (assoc "Content-Length" headers))))
(if content-length
(string-to-number content-length)
;; This usually means either the server or our parser is
;; screwed up with a previous Content-Length
(error "No Content-Length header")))
body-received 0
body nil
chunk (substring-no-properties chunk (+ body-sep-pos 4)))
;; Haven't found the end of the headers yet. Save everything
;; for when the next chunk arrives and await further input.
(setf leftovers chunk
chunk nil))
(let* ((chunk-length (string-bytes chunk))
(left-to-receive (- body-length body-received))
(this-body (if (< left-to-receive chunk-length)
(prog1 (substring-no-properties chunk 0 left-to-receive)
(setf chunk (substring-no-properties chunk left-to-receive)))
(prog1 chunk
(setf chunk nil))))
(body-bytes (string-bytes this-body)))
(push this-body body)
(setf body-received (+ body-received body-bytes))
(when (>= chunk-length left-to-receive)
(condition-case err
(with-temp-buffer
(apply #'insert
(nreverse
(prog1 body
(setf body-length nil
body-received 0
body nil))))
(decode-coding-region (point-min)
(point-max)
'utf-8)
(goto-char (point-min))
(push (eca-api--json-read-buffer) messages))
(error
(setf body-length nil
body-received 0
body nil)
(eca-warn "Failed to parse the following chunk:\n'''\n%s\n'''\nwith message %s"
(concat leftovers raw-output)
err)))))))
(mapc handle-msg
(nreverse messages)))))))
;; Public
(defun eca-process-start (session on-start handle-msg)
"Start the eca process for SESSION calling ON-START after.
Call HANDLE-MSG for new msgs processed."
(unless (process-live-p (eca--session-process session))
;; Clean up any .old binary from previous updates
(eca-process--cleanup-old-server)
(-let* (((result &as &plist :decision decision :command command) (eca-process--server-command))
(start-process-fn (lambda ()
(let ((command (append command eca-extra-args)))
(when eca-process-wrapper-function
(setq command
(funcall eca-process-wrapper-function
command
(eca--session-workspace-folders session))))
(eca-info "Starting process '%s'" (string-join command " "))
(setf (eca--session-process session)
(make-process
:coding 'no-conversion
:connection-type 'pipe
:name "eca"
:command command
:buffer (eca-process--buffer-name session)
:stderr (get-buffer-create (eca-process--stderr-buffer-name session))
:filter (eca-process--make-filter handle-msg)
:sentinel (lambda (process exit-str)
(unless (process-live-p process)
(when-let* ((name (eca-process--stderr-buffer-name session))
(buf (get-buffer name)))
(with-current-buffer buf
(rename-buffer (concat (buffer-name) ":closed") t)
(setq-local mode-line-format '("*Closed session*"))))
(eca-delete-session session)
(eca-info "process has exited (%s)" (s-trim exit-str))))
:file-handler t
:noquery t)))
(funcall on-start))))
(pcase decision
('custom (funcall start-process-fn))
('system (funcall start-process-fn))
('error-download (user-error (eca-error (plist-get result :message))))
('already-installed (funcall start-process-fn))
('download (eca-process--download-server
(lambda () (funcall start-process-fn))
(plist-get result :latest-version)
;; When updating an already installed server, a
;; failed download (e.g. GitHub outage) falls back
;; to starting the installed binary.
(when (f-exists? eca-server-install-path)
(lambda (err)
(eca-warn "Failed to download eca server (%s), starting installed version instead" err)
(funcall start-process-fn)))))))))
(defun eca-process-running-p (session)
"Return non nil if eca process for SESSION is running."
(and session
(process-live-p (eca--session-process session))))
(defun eca-process-stop (session)
"Stop the eca process for SESSION if running."
(when session
(kill-process (eca--session-process session))
(kill-buffer (eca-process--buffer-name session))
;; Rename stderr buffer to closed and clean up older closed ones
(let ((stderr-buffer (get-buffer (eca-process--stderr-buffer-name session))))
(when stderr-buffer
(with-current-buffer stderr-buffer
(rename-buffer (concat (buffer-name) ":closed") t)
(setq-local mode-line-format '("*Closed session*"))
(when-let ((win (get-buffer-window (current-buffer))))
(quit-window nil win))
;; Keep only the most recently closed stderr buffer; kill older ones.
;; Only kill :closed buffers — never non-closed ones which belong
;; to active sessions.
(let ((current (current-buffer)))
(dolist (b (buffer-list))
(when (and (not (eq b current))
(string-match-p "^<eca:stderr:.*>:closed" (buffer-name b)))
(kill-buffer b)))))))))
(defun eca-process-show-stderr (session)
"Open the eca process stderr buffer for SESSION."
(if-let ((buf (get-buffer (eca-process--stderr-buffer-name session))))
(if (window-live-p (get-buffer-window buf))
(select-window (get-buffer-window buf))
(display-buffer buf))
(message "No stderr buffer for session %d"
(eca--session-id session))))
(defun eca-process--server-version ()
"Return the server version by running the eca binary with --version.
Anchors at the session's first workspace folder when a session
exists, so remote (TRAMP) sessions report the remote binary
version regardless of the buffer this is called from."
(when-let* ((default-directory (or (-some-> (eca-session)
(eca--session-workspace-folders)
(car))
default-directory))
(binary (or (car eca-custom-command)
(executable-find "eca" (file-remote-p default-directory))
(and (f-exists? eca-server-install-path)
eca-server-install-path)))
(output (ignore-errors
(string-trim
(if (file-remote-p binary)
(let ((default-directory (file-name-directory binary)))
(shell-command-to-string
(format "%s --version 2>/dev/null"
(shell-quote-argument
(file-local-name binary)))))
(shell-command-to-string
(format "%s --version 2>/dev/null"
(shell-quote-argument
(expand-file-name binary)))))))))
(unless (string-empty-p output)
output)))
;;;###autoload
(defun eca-show-stderr ()
"Open the eca process stderr buffer if running."
(interactive)
(eca-process-show-stderr (eca-session)))
;;;###autoload
(defun eca-install-server ()
"Force download the latest eca server.
Clears `eca-process--releases-cache' and the fetch failure cooldown
first so the latest version is re-checked against GitHub even within
a long-running Emacs session."
(interactive)
(setq eca-process--releases-cache nil
eca-process--releases-fetch-failed-at nil)
(eca-process--download-server (lambda ())
(eca-process--get-latest-server-version)))
;;;###autoload
(defun eca-server-check-updates ()
"Check GitHub for a newer eca server release.
Bypasses `eca-process--releases-cache' (and thus
`eca-server-releases-cache-ttl') so the answer is always fresh.
Reports via the echo area whether the installed server is up to date,
whether a newer version is available, or whether the check failed."
(interactive)
(setq eca-process--releases-cache nil
eca-process--releases-fetch-failed-at nil)
(let ((latest (eca-process--get-latest-server-version))
(current (eca-process--get-current-server-version)))
(cond
((null latest)
(eca-warn "Could not check for eca server updates."))
((not (f-exists? eca-server-install-path))
(eca-info
(concat "No eca server installed; latest available is %s. "
"Run M-x eca-install-server to install.")
latest))
((null current)
(eca-info
(concat "eca server is installed but its version is unknown; "
"it will be reinstalled (%s) on next start. "
"Run M-x eca-install-server to reinstall now.")
latest))
((string-version-lessp current latest)
(eca-info
(concat "eca server %s is available (installed: %s). "
"Run M-x eca-install-server to upgrade.")
latest current))
(t
(eca-info "eca server is up to date (%s)." current)))))
;;;###autoload
(defun eca-uninstall-server ()
"Remove downloaded eca server if present."
(interactive)
(eca-process--uninstall-server)
(eca-info "Server uninstalled!"))
(provide 'eca-process)
;;; eca-process.el ends here