ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/cvsroot/AnyEvent-MCP/t/16_vim_auth.t
Revision: 1.11
Committed: Wed Oct 14 06:54:52 2026 UTC (2 weeks, 1 day ago) by root
Content type: application/x-troff
Branch: MAIN
Changes since 1.10: +25 -23 lines
Log Message:
*** empty log message ***

File Contents

# User Rev Content
1 root 1.1 use common::sense;
2     use Test::More;
3     use JSON::XS ();
4     use IO::Socket::INET ();
5     use IO::Select ();
6     use Protocol::WebSocket::Frame ();
7     use IPC::Open3 ();
8     use POSIX qw(WNOHANG);
9     use Time::HiRes ();
10     use File::Temp ();
11     use File::Path ();
12     use Symbol ();
13    
14     my $dir = File::Temp::tempdir (CLEANUP => 1);
15     File::Path::mkpath (["$dir/home", "$dir/config"], 0, 0700);
16     local $ENV{HOME} = "$dir/home";
17     local $ENV{XDG_CONFIG_HOME} = "$dir/config";
18    
19     my $listener = IO::Socket::INET->new (
20     LocalAddr => "127.0.0.1", LocalPort => 0, Proto => "tcp", Listen => 1,
21     ) or die "allocate TCP test port: $!";
22     my $port = $listener->sockport;
23     close $listener;
24    
25     my $err = Symbol::gensym;
26 root 1.2 local $ENV{PERL_ANYEVENT_VERBOSE} = 6; # include notice-level rejection logs
27 root 1.1 my $pid = IPC::Open3::open3 (my $in, my $out, $err, $^X, "-Ilib",
28 root 1.9 "-e", 'unshift @INC, sub { die "Coro must not be loaded\\n" if $_[1] =~ m{^Coro(?:/|\\.pm)}; return }; do shift @ARGV; die $@ if $@;',
29     "./bin/mcp-vim", "--stdio", "--bind", "127.0.0.1:$port",
30 root 1.1 "--token", "primary", "--token", "secondary");
31     my $old = select $in;
32     $| = 1;
33     select $old;
34    
35     sub tcp {
36     IO::Socket::INET->new (
37     PeerAddr => "127.0.0.1", PeerPort => $port,
38     Proto => "tcp", Timeout => 1,
39     )
40     }
41    
42     my $ready;
43     for (1 .. 60) {
44     $ready = tcp ();
45     last if $ready;
46     Time::HiRes::sleep (0.03);
47     }
48     ok $ready, "mcp-vim listens on TCP with two static tokens";
49 root 1.7 # leave this connection silent while the other protocols are exercised.
50 root 1.1
51     sub rpc {
52     my ($id, $method, $params) = @_;
53     print {$in} JSON::XS::encode_json ({
54     jsonrpc => "2.0", id => $id, method => $method, params => $params,
55     }), "\n" or die "send JSONL request: $!";
56     IO::Select->new ($out)->can_read (3) or die "MCP stdio response timed out";
57     my $line = <$out> // die "unexpected MCP stdio EOF";
58     JSON::XS::decode_json $line
59     }
60    
61     sub target_names {
62     my $r = rpc (++$main::request_id, "tools/call",
63 root 1.3 { name => "targets", arguments => {} });
64 root 1.1 return [] unless $r->{result}{content}[0]{text};
65     JSON::XS::decode_json $r->{result}{content}[0]{text}
66     }
67    
68     sub vim_connect {
69     my ($token, $target) = @_;
70     my $sock = tcp () or die "connect private Vim TCP: $!";
71     my $hello = JSON::XS::encode_json ([0, [
72     "mcp-vim-connect", 1, 0, $token, $target, "test-$target",
73     ]]);
74     print {$sock} $hello or die "send private Vim hello: $!";
75     $sock
76     }
77    
78     sub upgrade {
79 root 1.2 my ($token, $path) = @_;
80 root 1.1 my $sock = tcp () or die "connect browser WS TCP: $!";
81     $sock->autoflush (1);
82 root 1.2 $path //= "/$token/http://page.example:818";
83     my $req = "GET $path HTTP/1.1\r\n"
84 root 1.1 . "Host: 127.0.0.1:$port\r\n"
85     . "Upgrade: websocket\r\nConnection: Upgrade\r\n"
86     . "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
87     . "Sec-WebSocket-Version: 13\r\n"
88     . "Origin: http://page.example:818\r\n\r\n";
89     print {$sock} $req or die "send WebSocket upgrade: $!";
90    
91     my $bytes = "";
92     my $deadline = Time::HiRes::time () + 2;
93     while ($bytes !~ /\r\n\r\n/ && Time::HiRes::time () < $deadline) {
94     last unless IO::Select->new ($sock)->can_read (0.2);
95     my $n = sysread $sock, my $chunk, 4096;
96     last unless $n;
97     $bytes .= $chunk;
98     }
99    
100     my ($headers, $frames) = split /\r\n\r\n/, $bytes, 2;
101     ($sock, $headers // "", $frames // "")
102     }
103    
104     sub ws_close_code {
105     my ($sock, $initial) = @_;
106     my $frame = Protocol::WebSocket::Frame->new;
107     $frame->append ($initial) if length $initial;
108     my $deadline = Time::HiRes::time () + 3;
109     while (Time::HiRes::time () < $deadline) {
110     while (defined (my $payload = $frame->next_bytes)) {
111     return unpack ("n", $payload) if $frame->opcode == 8 && length ($payload) >= 2;
112     }
113     next unless IO::Select->new ($sock)->can_read (0.2);
114     my $n = sysread $sock, my $bytes, 8192;
115     last unless $n;
116     $frame->append ($bytes);
117     }
118     undef
119     }
120    
121     my $init = rpc (1, "initialize", {
122     protocolVersion => "2025-11-25", capabilities => {},
123     clientInfo => { name => "auth-test", version => "0" },
124     });
125     ok !$init->{error} && $init->{result}{serverInfo},
126 root 1.7 "stdio control channel initializes while a TCP peer sends no first byte";
127 root 1.4 is $init->{result}{serverInfo}{name}, "mcp-vim", "stdio reports mcp-vim as its identity";
128     like $init->{result}{instructions}, qr/live Vim editors and buffers/,
129     "stdio gives MCP clients live-buffer guidance";
130     unlike JSON::XS::encode_json ($init->{result}), qr/xpcse/i,
131     "stdio initialization does not expose the legacy browser name";
132    
133     my $discover = rpc (2, "server/discover", {});
134     is $discover->{result}{instructions}, $init->{result}{instructions},
135     "discovery gives the same Vim instructions as initialize";
136    
137     {
138     my $http = tcp () or die "connect MCP HTTP: $!";
139     my $body = JSON::XS::encode_json ({
140     jsonrpc => "2.0", id => 3, method => "initialize",
141     params => {
142     protocolVersion => "2025-11-25", capabilities => {},
143     clientInfo => { name => "auth-test", version => "0" },
144     },
145     });
146     print {$http} "POST / HTTP/1.1\r\n",
147     "Host: 127.0.0.1:$port\r\n",
148     "Authorization: Bearer primary\r\n",
149     "Content-Type: application/json\r\n",
150     "Content-Length: ", length $body, "\r\n\r\n", $body
151     or die "send MCP HTTP request: $!";
152     local $/;
153     my $response = <$http> // "";
154     close $http;
155     my ($headers, $json) = split /\r\n\r\n/, $response, 2;
156     like $headers, qr{\AHTTP/1\.1 200\b}, "HTTP initialization succeeds";
157     my $result = eval { JSON::XS::decode_json $json };
158     is $result->{result}{serverInfo}{name}, "mcp-vim", "HTTP reports mcp-vim as its identity";
159     is $result->{result}{instructions}, $init->{result}{instructions},
160     "HTTP gives the same Vim instructions as stdio";
161     }
162 root 1.1
163 root 1.8 my $empty_state = rpc (4, "tools/call", { name => "state", arguments => {} });
164     is_deeply JSON::XS::decode_json ($empty_state->{result}{content}[0]{text}), {},
165     "state with no connected editors completes immediately with an empty object";
166    
167 root 1.1 my $bad = vim_connect ("unknown", "bad-vim");
168     my $before = target_names ();
169     ok !grep ($_ eq "bad-vim", @$before), "unknown private Vim token cannot register a target";
170     close $bad;
171    
172     my $valid = vim_connect ("secondary", "secondary-vim");
173     my $visible;
174     for (1 .. 35) {
175     my $names = target_names ();
176     $visible = grep $_ eq "secondary-vim", @$names;
177     last if $visible;
178     Time::HiRes::sleep (0.03);
179     }
180     ok $visible, "secondary token authenticates private Vim handshake";
181    
182 root 1.6 # A private Vim RPC supplying disjoint ranges must yield a separate
183     # [range=...] metadata text entry and an unchanged lines text entry for each.
184 root 1.5 print {$in} JSON::XS::encode_json ({
185     jsonrpc => "2.0", id => 180, method => "tools/call",
186     params => { name => "read-lines", arguments => {
187     target => "secondary-vim", buffer => 1, ranges => ["727,728", "985,986"],
188     } },
189     }), "\n" or die "send Vim read-lines request: $!";
190    
191     my $wire = "";
192     my $vim_call;
193     for (1 .. 15) {
194     IO::Select->new ($valid)->can_read (0.3)
195     or next;
196     my $n = sysread $valid, my $fragment, 8192;
197     last unless $n;
198     $wire .= $fragment;
199     $vim_call = eval { JSON::XS::decode_json $wire };
200     last if $vim_call;
201     }
202     ok $vim_call && $vim_call->[1][0] eq "call"
203     && $vim_call->[1][2] eq "read_lines",
204     "read-lines delegates disjoint ranges to the private Vim RPC";
205     if ($vim_call) {
206     $valid->autoflush (1);
207     print {$valid} JSON::XS::encode_json ([0, [
208     reply => $vim_call->[1][1], 1, {
209     changedtick => 7,
210     ranges => [
211     { range => "727,728", start => 727, end => 728, lines => ["function alpha () {", " return a;"] },
212     { range => "985,986", start => 985, end => 986, lines => ["function beta () {", " return b;"] },
213     ],
214     },
215     ]]) or die "send mock Vim RPC reply: $!";
216     }
217     IO::Select->new ($out)->can_read (5) or die "read-lines MCP reply timeout";
218     my $lines_reply = JSON::XS::decode_json (scalar <$out>);
219     my $blocks = $lines_reply->{result}{content} || [];
220 root 1.6 is @{$blocks}, 5, "Vim returns initial metadata plus two text entries per requested range";
221     is_deeply [map $_->{type}, @$blocks], [("text") x 5],
222     "all Vim metadata and source blocks remain MCP text entries";
223 root 1.5 is_deeply JSON::XS::decode_json ($blocks->[0]{text}), {
224     changedtick => 7,
225     ranges => [
226     { range => "727,728", start => 727, end => 728 },
227     { range => "985,986", start => 985, end => 986 },
228     ],
229     }, "Vim changedtick and resolved range metadata remain available";
230 root 1.6 is_deeply [map $_->{text}, @{$blocks}[1..4]], [
231     "[range=727,728]",
232     "function alpha () {\n return a;\n",
233     "[range=985,986]",
234     "function beta () {\n return b;\n",
235     ], "each Vim range has visible standalone metadata and unchanged newline-terminated source";
236 root 1.5
237    
238 root 1.7 sub send_tool {
239     my ($id, $name, $args) = @_;
240     print {$in} JSON::XS::encode_json ({
241     jsonrpc => "2.0", id => $id, method => "tools/call",
242     params => { name => $name, arguments => $args },
243     }), "\n" or die "send tool request: $!";
244     }
245    
246 root 1.9 my %vim_parser;
247    
248 root 1.7 sub vim_call {
249 root 1.8 my $socket = $_[0] || $valid;
250 root 1.9 my $parser = $vim_parser{fileno $socket} ||= JSON::XS->new->utf8;
251 root 1.7 for (1 .. 20) {
252 root 1.9 my $packet = $parser->incr_parse;
253     return $packet if $packet;
254 root 1.8 next unless IO::Select->new ($socket)->can_read (0.25);
255     my $n = sysread $socket, my $fragment, 8192;
256 root 1.7 last unless $n;
257 root 1.9 $parser->incr_parse ($fragment); # void context: leave complete packets for the next iteration
258 root 1.7 }
259 root 1.9 die "private Vim message timeout\n";
260 root 1.7 }
261    
262     sub vim_reply {
263 root 1.8 my ($call, $success, $result, $socket) = @_;
264     $socket ||= $valid;
265     print {$socket} JSON::XS::encode_json ([0, [
266 root 1.7 reply => $call->[1][1], $success, $result,
267     ]]) or die "send Vim RPC reply: $!";
268     }
269    
270     sub mcp_reply {
271     IO::Select->new ($out)->can_read (5) or die "MCP reply timeout\n";
272     JSON::XS::decode_json (scalar <$out>)
273     }
274    
275 root 1.8 my $other = vim_connect ("secondary", "parallel-vim");
276     $other->autoflush (1);
277     my $other_visible;
278     for (1 .. 35) {
279     $other_visible = grep $_ eq "parallel-vim", @{target_names ()};
280     last if $other_visible;
281     Time::HiRes::sleep (0.03);
282     }
283     ok $other_visible, "second mock editor connected for parallel state collection";
284    
285     send_tool 181, "state", {};
286     my $first_state_call = vim_call;
287     my $second_state_call = vim_call $other;
288     is_deeply [$first_state_call->[1][2], $second_state_call->[1][2]], ["state", "state"],
289     "state sends both editor requests before waiting for either reply";
290     my $first_state = { cwd => "/first", buffers => [] };
291     my $second_state = { cwd => "/second", buffers => [] };
292     vim_reply $second_state_call, 1, $second_state, $other;
293     vim_reply $first_state_call, 1, $first_state;
294     my $states = mcp_reply;
295     is $states->{id}, 181, "parallel state completes the original request";
296     is_deeply JSON::XS::decode_json ($states->{result}{content}[0]{text}), {
297     "secondary-vim" => $first_state,
298     "parallel-vim" => $second_state,
299     }, "state collects both editor replies under their target names";
300    
301     send_tool 182, "state", {};
302     my $failed_state_call = vim_call;
303     my $late_state_call = vim_call $other;
304     vim_reply $failed_state_call, 0, "mock state error";
305     my $state_error = mcp_reply;
306     ok $state_error->{id} == 182 && $state_error->{result}{isError}
307     && $state_error->{result}{content}[0]{text} =~ /mock state error/,
308     "state fails without waiting for the other editor";
309     vim_reply $late_state_call, 1, $second_state, $other;
310    
311     send_tool 183, "state", { target => "secondary-vim" };
312     my $selected_state_call = vim_call;
313     vim_reply $selected_state_call, 1, $first_state;
314     my $selected_state = mcp_reply;
315     is $selected_state->{id}, 183, "late state reply does not complete a failed request again";
316     is_deeply JSON::XS::decode_json ($selected_state->{result}{content}[0]{text}),
317     { "secondary-vim" => $first_state }, "state can select one editor";
318     close $other;
319    
320 root 1.7 my $tool_id = 190;
321     for my $case (
322     ["grep", {
323     target => "secondary-vim", buffers => [1], pattern => "alpha",
324     mode => "word", max_results => 2,
325     }, "grep", [[1], "alpha", "word", 2]],
326     ["apply-diff", {
327     target => "secondary-vim", buffer => 1,
328     diff => "@@ -1 @@\n-old\n+new\n", expected_changedtick => 7,
329     }, "apply_patch", [1, "@@ -1,1 +1,1 @@\n-old\n+new\n", [0], 7]],
330     ["substitute", {
331     target => "secondary-vim", buffer => 1, range => "%",
332     pattern => "alpha", replacement => "beta", flags => "g",
333     expected_changedtick => 7,
334     }, "substitute", [1, "%", "alpha", "beta", "g", 7]],
335     ["set-cursor", {
336     target => "secondary-vim", buffer => 1, line => 2, column => 3,
337     expected_changedtick => 7,
338     }, "set_cursor", [1, 2, 3, 7]],
339     ["set-cursor", {
340     target => "secondary-vim", buffer => 1, line => 2, column => 3,
341     }, "set_cursor", [1, 2, 3]],
342     ) {
343     my ($name, $args, $command, $rpc_args) = @$case;
344     send_tool ++$tool_id, $name, $args;
345     my $call = vim_call;
346     is_deeply [@{$call->[1]}[2 .. $#{$call->[1]}]], [$command, @$rpc_args],
347     "$name forwards its arguments and guards through callback RPC";
348     my $result = { changedtick => 8, tool => $name };
349     vim_reply $call, 1, $result;
350     my $reply = mcp_reply;
351     is $reply->{id}, $tool_id, "$name completes the original MCP request";
352     is_deeply JSON::XS::decode_json ($reply->{result}{content}[0]{text}), $result,
353     "$name preserves the JSON tool result";
354     }
355    
356     my $grep_args = {
357     target => "secondary-vim", buffers => [1], pattern => "alpha",
358     mode => "word", max_results => 2,
359     };
360     send_tool 196, "grep", $grep_args;
361     my $failed_call = vim_call;
362     vim_reply $failed_call, 0, "mock Vim error";
363     my $failed_reply = mcp_reply;
364     ok $failed_reply->{id} == 196 && $failed_reply->{result}{isError}
365     && $failed_reply->{result}{content}[0]{text} =~ /mock Vim error/,
366     "failed Vim RPC becomes an MCP tool error";
367    
368     send_tool 197, "grep", $grep_args;
369     my $cancelled_call = vim_call;
370     print {$in} JSON::XS::encode_json ({
371     jsonrpc => "2.0", method => "notifications/cancelled",
372     params => { requestId => 197 },
373     }), "\n" or die "cancel tool request: $!";
374     is rpc (198, "ping", {})->{id}, 198, "cancellation processed before the late Vim reply";
375     send_tool 199, "grep", $grep_args;
376     my $next_call = vim_call;
377     vim_reply $cancelled_call, 1, { cancelled => 1 };
378     vim_reply $next_call, 1, { after_cancel => 1 };
379     my $after_cancel = mcp_reply;
380     ok $after_cancel->{id} == 199 && !$after_cancel->{result}{isError}
381     && JSON::XS::decode_json ($after_cancel->{result}{content}[0]{text})->{after_cancel},
382     "late cancelled reply is ignored and the next tool call completes";
383    
384 root 1.2 my ($invalid_ws, $invalid_headers) = upgrade ("secret-do-not-log-XYZ");
385 root 1.1 unlike $invalid_headers, qr{\AHTTP/1\.[01] 101\b},
386     "unknown WebSocket URL token does not upgrade";
387     close $invalid_ws;
388    
389 root 1.2 my ($malformed_ws, $malformed_headers) = upgrade ("secondary", "/secondary/");
390     unlike $malformed_headers, qr{\AHTTP/1\.[01] 101\b},
391     "malformed WebSocket URL rejected even when it contains a valid token";
392     close $malformed_ws;
393    
394 root 1.10 {
395 root 1.1 my ($sock, $headers, $pending) = upgrade ("secondary");
396     like $headers, qr{\AHTTP/1\.1 101\b},
397 root 1.10 "secondary token authenticates xpcse WebSocket handshake";
398 root 1.1 if ($headers =~ /\AHTTP\/1\.1 101\b/) {
399 root 1.10 my $payload = JSON::XS::encode_json (["xpcse-client", 99]);
400 root 1.1 my $frame = Protocol::WebSocket::Frame->new (
401     type => "text", masked => 1, buffer => $payload,
402     );
403 root 1.10 print {$sock} $frame->to_bytes or die "send xpcse mismatch: $!";
404 root 1.1 is ws_close_code ($sock, $pending), 1002,
405 root 1.10 "xpcse version mismatch triggers protocol-error close";
406 root 1.1 } else {
407 root 1.10 fail "xpcse version mismatch closes connection";
408 root 1.1 }
409     close $sock;
410     }
411    
412 root 1.9 # browser transfers use the same broker, with Coro unavailable.
413     sub browser {
414     my ($sock, $headers, $pending) = upgrade ("secondary");
415     $headers =~ /\AHTTP\/1\.1 101\b/ or die "browser upgrade failed\n";
416 root 1.11 my $frame = Protocol::WebSocket::Frame->new (max_payload_size => 100e6);
417 root 1.9 $frame->append ($pending);
418     my $browser = [$sock, $frame];
419     ws_message ($browser); # nick
420     ws_message ($browser); # targets
421     $browser
422     }
423    
424     sub ws_message {
425     my ($browser, $timeout) = @_;
426     $timeout //= 3;
427     my ($socket, $frame) = @$browser;
428     my $deadline = Time::HiRes::time () + $timeout;
429     while (1) {
430     while (defined (my $payload = $frame->next_bytes)) {
431     next if $frame->opcode == 10;
432     return [$frame->opcode, $payload];
433     }
434     my $left = $deadline - Time::HiRes::time ();
435     return if $left <= 0 || !IO::Select->new ($socket)->can_read ($left);
436     my $n = sysread $socket, my $bytes, 65536;
437     return unless $n;
438     $frame->append ($bytes);
439     }
440     }
441    
442     sub browser_send {
443     my ($browser, $message) = @_;
444     print {$browser->[0]} Protocol::WebSocket::Frame->new (
445     type => "text", masked => 1, buffer => JSON::XS::encode_json ($message),
446     )->to_bytes or die "send browser message: $!";
447     }
448    
449     sub vim_send {
450     print {$valid} JSON::XS::encode_json ([0, $_[0]]) or die "send Vim message: $!";
451     }
452    
453     sub browser_json {
454     my $message = ws_message $_[0];
455     $message && $message->[0] == 1 or die "expected browser JSON\n";
456     JSON::XS::decode_json $message->[1]
457     }
458    
459     vim_send [session => "save-test", {
460     origin => "http://page.example:818", filename => "test.txt",
461     }];
462    
463     my $browser = browser ();
464     vim_send [save => "save-test"];
465     like vim_call ()->[1][1], qr/requesting save/, "save reports its destination";
466     is browser_json ($browser)->[0], "upload", "save starts a browser upload";
467    
468     require MIME::Base64;
469     my $data = "x" x (10e6 + 37);
470     vim_send [data => MIME::Base64::encode_base64 ($data, "")];
471 root 1.11
472     my $message = ws_message $browser or die "oversized upload chunk stalled\n";
473     is $message->[0], 2, "upload frame is binary";
474     ok $message->[1] eq $data, "one oversized Vim chunk is forwarded whole";
475     ok !IO::Select->new ($valid)->can_read (0.1), "receiving Vim data does not acknowledge browser receipt";
476    
477     # further chunks wait while credit is negative; eof and a queued save may already arrive.
478     my @tail = ("first tail", "", "second tail");
479     vim_send [data => MIME::Base64::encode_base64 ($_, "")] for @tail;
480     vim_send [eof => 1];
481     vim_send [save => "save-test"];
482     like vim_call ()->[1][1], qr/requesting save/, "another save queues without acknowledging unsent data";
483 root 1.9 vim_send [eof => 1];
484 root 1.11 ok !ws_message ($browser, 0.1), "negative credit holds back the next whole chunk";
485 root 1.9
486 root 1.11 browser_send $browser, [ack => length $data];
487     is_deeply vim_call ()->[1], [ack => length $data], "browser receipt is forwarded to the originating Vim after its eof";
488     for my $tail (@tail) {
489     my $message = ws_message $browser or die "upload did not resume after acknowledgement\n";
490     is_deeply $message, [2, $tail], "acknowledgement resumes output with the original chunk boundary";
491 root 1.9 }
492 root 1.11 is_deeply browser_json ($browser), ["upload-done"], "eof follows all input chunks";
493 root 1.9 ok !ws_message ($browser, 0.1), "next upload waits for the browser's completion reply";
494 root 1.11 for my $tail (@tail) {
495     browser_send $browser, [ack => length $tail];
496     is_deeply vim_call ()->[1], [ack => length $tail], "each browser chunk acknowledgement reaches Vim";
497     }
498 root 1.9 browser_send $browser, ["upload-done", "200", "saved first"];
499 root 1.11 is_deeply vim_call ()->[1], [echo => "200 saved first"], "save completion remains separate from chunk receipt";
500 root 1.9 is browser_json ($browser)->[0], "upload", "completion releases the queued save";
501     is_deeply browser_json ($browser), ["upload-done"], "empty save sends eof without waiting for credit";
502     browser_send $browser, ["upload-done", "500", "upload ok, but errors:\nat line 12, broken\n\n"];
503     is_deeply vim_call ()->[1], [error => "error: at line 12, broken"], "multiline upload error retains its diagnostic";
504     is_deeply vim_call ()->[1], [goto => "save-test", 12, 1], "upload error retains its session and line";
505    
506     vim_send [save => "save-test"];
507     vim_call ();
508     is browser_json ($browser)->[0], "upload", "another upload starts after an error reply";
509     vim_send [eof => 0];
510     my $aborted = ws_message $browser;
511     is $aborted->[0], 8, "failed Vim eof closes the active upload instead of posting a partial file";
512     is_deeply vim_call ()->[1], [echo => "save-test: browser disconnected"], "aborted upload releases its Vim input";
513     close $browser->[0];
514    
515     # rejected saves still consume their input and leave RPC dispatch usable.
516     vim_send [save => "save-test"];
517     like vim_call ()->[1][1], qr/no origin found/, "save without a browser explains how to reconnect";
518     vim_send [data => MIME::Base64::encode_base64 ("ignored", "")];
519     vim_send [eof => 1];
520     is_deeply vim_call ()->[1], [ack => 7], "rejected save consumes subsequent data";
521    
522     $browser = browser ();
523     vim_send [save => "save-test"];
524     vim_call ();
525     browser_json $browser;
526     close $browser->[0];
527     is_deeply vim_call ()->[1], [echo => "save-test: browser disconnected"], "browser disconnect releases an unfinished save";
528    
529     my $leaving = vim_connect ("secondary", "leaving-vim");
530     $leaving->autoflush (1);
531     for (1 .. 35) {
532     last if grep $_ eq "leaving-vim", @{target_names ()};
533     Time::HiRes::sleep (0.03);
534     }
535     $browser = browser ();
536     print {$leaving} JSON::XS::encode_json ([0, [session => "leaving", {
537     origin => "http://page.example:818", filename => "leaving.txt",
538     }]]), JSON::XS::encode_json ([0, [save => "leaving"]]);
539     vim_call $leaving;
540     is browser_json ($browser)->[0], "upload", "disconnect test starts an unfinished upload";
541     close $leaving;
542     is ws_message ($browser)->[0], 8, "Vim disconnect aborts its unfinished browser upload";
543     close $browser->[0];
544    
545 root 1.7 send_tool 200, "grep", $grep_args;
546     my $disconnected_call = vim_call;
547 root 1.1 close $valid;
548 root 1.7 my $disconnected_reply = mcp_reply;
549     ok $disconnected_reply->{id} == 200 && $disconnected_reply->{result}{isError}
550     && $disconnected_reply->{result}{content}[0]{text} =~ /vim disconnected/,
551     "disconnect fails an outstanding callback RPC";
552     close $ready if $ready;
553 root 1.1 close $in;
554     my $exit;
555     for (1 .. 120) {
556     my $w = waitpid ($pid, WNOHANG);
557     if ($w == $pid) {
558     $exit = $? >> 8;
559     last;
560     }
561     Time::HiRes::sleep (0.025);
562     }
563     if (!defined $exit) {
564     kill TERM => $pid;
565     waitpid ($pid, 0);
566     $exit = -1;
567     }
568     is $exit, 0, "broker exits after stdio EOF";
569     close $out;
570 root 1.2 local $/;
571     my $log = <$err> // "";
572     like $log, qr/illegal websocket connect from 127\.0\.0\.1:\d+/,
573     "rejected websocket handshake logs the numeric TCP peer and port";
574     unlike $log, qr/secret-do-not-log-XYZ|\/secondary\//,
575     "rejected URL never exposes supplied credentials in the log";
576 root 1.1 close $err;
577    
578     done_testing;