Skip to content

Commit dcf8459

Browse files
committed
Add browser connection waiting
1 parent 6ec370b commit dcf8459

3 files changed

Lines changed: 138 additions & 36 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ The current phase provides:
1111
- one `App`, multiple isolated windows, and automatic port selection;
1212
- embedded HTML, static directories, custom resources, external URLs, and a
1313
built-in JavaScript bridge;
14+
- explicit browser connection waiting and timeout through
15+
`Window.waitForConnection()`;
1416
- JavaScript calls to Zig bindings with return values;
1517
- typed integer, float, and boolean call arguments and replies;
1618
- owned one-shot delayed binding replies through `Call.deferReply()`;
@@ -96,6 +98,11 @@ Building and using the library does not require Node or npm. `Window.evalAll`
9698
returns owned results; call `deinit` on them after consuming every per-client
9799
outcome.
98100

101+
`Window.open()` launches the browser and returns immediately. Call
102+
`Window.waitForConnection(io, timeout)` when startup must wait for a browser;
103+
it returns the first connected `Client`. `Window.eval()` uses the same total
104+
timeout for connection waiting and JavaScript execution.
105+
99106
Serve a directory by setting
100107
`.content = .{ .directory = "path/to/public" }`. The path is opened when the
101108
app starts and closed when it stops. Custom resources receive `webui.Request`

docs/PURE_ZIG_REFACTOR.md

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,6 @@ implementations.
298298
| `webui_show()`, `webui_set_root_folder()`, `webui_set_file_handler()`, `webui_set_file_handler_window()` | Content and resource handling can only be selected when creating a window; replacing them at runtime is not implemented. |
299299
| `webui_show_client()` | `Client` cannot replace the content of only one connected browser. |
300300
| `webui_is_shown()` | There is no window-level connected/shown query. |
301-
| `webui_set_config(show_wait_connection)`, `webui_set_timeout()` | `Window.open()` does not optionally wait for a browser connection. |
302301
| `webui_set_config(folder_monitor)` | Directory change monitoring and automatic browser reload are not implemented. |
303302
| `webui_set_default_root_folder()` | There is no application-wide default directory content setting. |
304303
| `webui_set_logger()` | There is no caller-provided logging callback. |
@@ -343,6 +342,7 @@ not implementation gaps:
343342
| `webui_return_string()`, `webui_return_int()`, `webui_return_float()`, `webui_return_bool()` | `Call.reply()`, `Call.replyInt()`, `Call.replyFloat()`, and `Call.replyBool()`. |
344343
| `webui_set_config(asynchronous_response)` | `Call.deferReply()` transfers the response to a bounded, owned, one-shot `PendingReply`. |
345344
| `webui_set_config(ui_event_blocking)`, `webui_set_event_blocking()` | `WindowOptions.event_mode` and `Window.setEventMode()` select serial or bounded concurrent binding and event execution. |
345+
| `webui_set_config(show_wait_connection)`, `webui_set_timeout()` | `Window.open()` remains non-blocking; callers explicitly compose it with `Window.waitForConnection(io, timeout)`. |
346346
| `webui_run()`, `webui_script()` | `Window.run()` and `Window.eval()`. |
347347
| `webui_run_client()`, `webui_script_client()` | `Client.run()` and `Client.eval()`. |
348348
| `webui_close_client()`, `webui_navigate_client()`, `webui_send_raw_client()` | `Client.close()`, `Client.navigate()`, and `Client.sendRaw()`. |
@@ -385,11 +385,9 @@ methods, and the public browser bridge surface.
385385

386386
### Handler and event lifecycle
387387

388-
- Add optional wait-for-connection behavior and a connection timeout.
389388
- Add a caller-provided logger.
390389

391-
This completes `webui_set_config(show_wait_connection)`,
392-
`webui_set_timeout()`, and `webui_set_logger()`.
390+
This completes `webui_set_logger()`.
393391

394392
### Dynamic content and client state
395393

@@ -490,5 +488,4 @@ zig build -Dtarget=aarch64-macos
490488

491489
Continue capability parity:
492490

493-
1. Add optional wait-for-connection behavior and a connection timeout.
494-
2. Add a caller-provided logger.
491+
1. Add a caller-provided logger.

src/app.zig

Lines changed: 128 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,11 @@ const ConnectedClient = struct {
232232
peer: Linsang.WebSocketPeer,
233233
};
234234

235+
const SelectedClient = struct {
236+
id: u64,
237+
peer: Linsang.WebSocketPeer,
238+
};
239+
235240
const DirectoryContent = struct {
236241
path: []u8,
237242
dir: ?std.Io.Dir = null,
@@ -734,50 +739,68 @@ const WindowState = struct {
734739
return sent;
735740
}
736741

737-
fn eval(
742+
fn waitForClient(
738743
self: *WindowState,
739744
io: std.Io,
740745
target_client_id: ?u64,
741-
script: []const u8,
742-
result_buffer: []u8,
743-
timeout: std.Io.Duration,
744-
) !EvalResult {
745-
try validateRunScript(script, self.limits.max_script_size);
746-
747-
const deadline = std.Io.Clock.Timestamp.fromNow(io, .{
748-
.clock = .awake,
749-
.raw = timeout,
750-
});
751-
var client_id: u64 = undefined;
752-
var peer: Linsang.WebSocketPeer = while (true) {
746+
require_single: bool,
747+
deadline: std.Io.Clock.Timestamp,
748+
) !SelectedClient {
749+
while (true) {
753750
self.mutex.lockUncancelable(io);
754751
if (target_client_id) |target| {
755752
if (self.clientIndexById(target)) |index| {
756-
client_id = target;
757-
const owned = self.clients.items[index].peer.clone();
753+
const selected = SelectedClient{
754+
.id = target,
755+
.peer = self.clients.items[index].peer.clone(),
756+
};
758757
self.mutex.unlock(io);
759-
break owned;
758+
return selected;
760759
}
761760
self.mutex.unlock(io);
762761
return error.ConnectionClosed;
763762
}
764-
if (self.clients.items.len == 1) {
765-
client_id = self.clients.items[0].id;
766-
const owned = self.clients.items[0].peer.clone();
767-
self.mutex.unlock(io);
768-
break owned;
769-
}
770-
if (self.clients.items.len > 1) {
763+
if (self.clients.items.len > 0) {
764+
if (require_single and self.clients.items.len > 1) {
765+
self.mutex.unlock(io);
766+
return error.MultipleClientsConnected;
767+
}
768+
const selected = SelectedClient{
769+
.id = self.clients.items[0].id,
770+
.peer = self.clients.items[0].peer.clone(),
771+
};
771772
self.mutex.unlock(io);
772-
return error.MultipleClientsConnected;
773+
return selected;
773774
}
774775
self.mutex.unlock(io);
775776
if (deadline.compare(.lte, .now(io, .awake))) return error.Timeout;
776-
// ponytail: polling is enough while one window owns the server;
777-
// replace it with an event when multiple windows are supported.
777+
// ponytail: 1 ms polling is enough for browser startup; use a
778+
// condition if sub-millisecond connection wakeups matter.
778779
try std.Io.sleep(io, .fromMilliseconds(1), .awake);
779-
};
780-
defer peer.deinit();
780+
}
781+
}
782+
783+
fn eval(
784+
self: *WindowState,
785+
io: std.Io,
786+
target_client_id: ?u64,
787+
script: []const u8,
788+
result_buffer: []u8,
789+
timeout: std.Io.Duration,
790+
) !EvalResult {
791+
try validateRunScript(script, self.limits.max_script_size);
792+
793+
const deadline = std.Io.Clock.Timestamp.fromNow(io, .{
794+
.clock = .awake,
795+
.raw = timeout,
796+
});
797+
var selected = try self.waitForClient(
798+
io,
799+
target_client_id,
800+
true,
801+
deadline,
802+
);
803+
defer selected.peer.deinit();
781804

782805
var pending: PendingEval = undefined;
783806
self.mutex.lockUncancelable(io);
@@ -787,7 +810,7 @@ const WindowState = struct {
787810
}
788811
pending = .{
789812
.id = self.nextEvalId(),
790-
.client_id = client_id,
813+
.client_id = selected.id,
791814
.buffer = result_buffer,
792815
};
793816
self.pending_evals.append(self.gpa, &pending) catch |err| {
@@ -804,7 +827,7 @@ const WindowState = struct {
804827
.id = pending.id,
805828
.command = .js,
806829
}, script);
807-
peer.sendBinary(packet.items) catch |err| switch (err) {
830+
selected.peer.sendBinary(packet.items) catch |err| switch (err) {
808831
error.Closed => return error.ConnectionClosed,
809832
else => return err,
810833
};
@@ -1132,6 +1155,22 @@ pub const Window = struct {
11321155
try browser.open(self.state.gpa, io, page_url);
11331156
}
11341157

1158+
/// Wait for at least one browser connection and return the first client.
1159+
pub fn waitForConnection(
1160+
self: Window,
1161+
io: std.Io,
1162+
timeout: std.Io.Duration,
1163+
) !Client {
1164+
var selected = try self.state.waitForClient(
1165+
io,
1166+
null,
1167+
false,
1168+
.fromNow(io, .{ .clock = .awake, .raw = timeout }),
1169+
);
1170+
defer selected.peer.deinit();
1171+
return .{ .state = self.state, .client_id = selected.id };
1172+
}
1173+
11351174
pub fn url(
11361175
self: Window,
11371176
running: *const Running,
@@ -2510,6 +2549,65 @@ fn authenticateTestClient(
25102549
return std.mem.eql(u8, response.payload, &.{1});
25112550
}
25122551

2552+
test "window connection waiting observes clients and timeouts" {
2553+
if (@import("builtin").os.tag != .linux) return error.SkipZigTest;
2554+
const gpa = std.testing.allocator;
2555+
var threaded = std.Io.Threaded.init(gpa, .{ .async_limit = .unlimited });
2556+
defer threaded.deinit();
2557+
const io = threaded.io();
2558+
2559+
var app = App.init(gpa, .{});
2560+
defer app.deinit();
2561+
const window = try app.createWindow(.{
2562+
.content = .{ .html = "connection wait test" },
2563+
});
2564+
var running = try app.start(io);
2565+
defer running.stop() catch {};
2566+
2567+
try std.testing.expectError(
2568+
error.Timeout,
2569+
window.waitForConnection(io, .fromMilliseconds(5)),
2570+
);
2571+
var waiting = io.async(Window.waitForConnection, .{
2572+
window,
2573+
io,
2574+
std.Io.Duration.fromSeconds(1),
2575+
});
2576+
defer _ = waiting.cancel(io) catch {};
2577+
2578+
const stream = try connectTestWebSocket(
2579+
running.inner.address,
2580+
io,
2581+
&window.state.capability,
2582+
);
2583+
defer stream.close(io);
2584+
var response_buffer: [125]u8 = undefined;
2585+
try std.testing.expect(try authenticateTestClient(
2586+
stream,
2587+
io,
2588+
gpa,
2589+
window.state.token,
2590+
&window.state.capability,
2591+
&response_buffer,
2592+
));
2593+
2594+
const delayed = try waiting.await(io);
2595+
try std.testing.expect(delayed.isConnected(io));
2596+
const immediate = try window.waitForConnection(io, .zero);
2597+
try std.testing.expectEqual(delayed.id(), immediate.id());
2598+
2599+
try stream.shutdown(io, .both);
2600+
for (0..100) |_| {
2601+
if (!delayed.isConnected(io)) break;
2602+
try std.Io.sleep(io, .fromMilliseconds(1), .awake);
2603+
}
2604+
try std.testing.expect(!delayed.isConnected(io));
2605+
try std.testing.expectError(
2606+
error.Timeout,
2607+
window.waitForConnection(io, .fromMilliseconds(5)),
2608+
);
2609+
}
2610+
25132611
test "binding replies can be deferred, bounded, and disconnected" {
25142612
if (@import("builtin").os.tag != .linux) return error.SkipZigTest;
25152613
const gpa = std.testing.allocator;

0 commit comments

Comments
 (0)