Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Tools/windows/Tests/WindowsShell.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,11 @@ Assert-Contract ($scrubbedStartupSource -match 'Invoke-Case "registered-project"
$daemonRoundTripSource -notmatch 'modelCompatibleFrame' -and
$daemonRoundTripSource -match 'self\.model\.updateFromFrame\(frame\)') `
"production daemon graph frames must reach the shell model unmodified and render a registered project"
Assert-Contract ($validationRunnerSource -match
'(?s)Scrubbed production shell startup.*?Real daemon wire round trip.*?& pwsh -NoProfile -File.*?DaemonRoundTrip\.Live\.Tests\.ps1.*?Native UI Automation live gate' -and
$daemonRoundTripSource -match 'DAEMON_OPEN_CANONICAL' -and
$daemonRoundTripSource -match 'eqlIgnoreCase\(&token') `
"Windows shell validation must exercise the production daemon wire contract, including canonical open replies"
$menuTimerBlock = [regex]::Match(
$appSource,
'(?s)else if \(wparam == MainWindow\.timer_id\) \{.*?const updated_connection_state'
Expand Down
8 changes: 8 additions & 0 deletions Tools/windows/validate.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -1209,6 +1209,14 @@ function Invoke-Task([string] $name) {
-Cli (Join-Path $daemonRuntime "graphcode.exe") `
-ScratchRoot (Join-Path $env:TEMP "scrubbed-shell-startup")
}
Invoke-Native "Real daemon wire round trip" {
& pwsh -NoProfile -File `
(Join-Path $repoRoot "Tools\windows\Tests\DaemonRoundTrip.Live.Tests.ps1") `
-DaemonExecutable (Join-Path $daemonRuntime "graphcoded.exe") `
-ZigExecutable $zig0152 `
-WinghosttyInclude (Join-Path $winghosttyRoot "include") `
-ScratchRoot (Join-Path $env:TEMP "daemon-roundtrip")
}
& (Join-Path $repoRoot "Tools\windows\Tests\TrayDaemon.Tests.ps1") `
-Executable (Join-Path $repoRoot "graphcode-windows\zig-out\bin\graphcode-windows.exe")
if ($LASTEXITCODE -ne 0) {
Expand Down
106 changes: 105 additions & 1 deletion graphcode-windows/src/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1201,6 +1201,38 @@ pub const App = struct {
return app;
}

/// The daemon answers an open with its canonical spelling of the project path
/// (for example `C:/a` for a picker's `C:\a`). When the reply is provably for the
/// shell's own in-flight open, adopt that spelling so the reply is not discarded.
fn adoptCanonicalOpenPath(self: *App, frame: []const u8, canonical: []const u8) void {
if (!self.pending_open_sent or self.pending_sent_path.len == 0) return;
if (std.mem.eql(u8, canonical, self.pending_sent_path)) return;
const owned = switch (self.client.protocolMode()) {
.v2 => blk: {
const request_id = self.pending_open_request_id orelse break :blk false;
const response_id = Wire.responseRequestID(frame) orelse break :blk false;
break :blk std.ascii.eqlIgnoreCase(response_id, &request_id);
},
.v1 => Wire.sameLocalProjectPath(canonical, self.pending_sent_path),
};
if (!owned) return;
const rebind_is_sent = std.mem.eql(u8, self.pending_rebind_path, self.pending_sent_path);
const sent = self.allocator.dupe(u8, canonical) catch return;
const rebind: ?[]u8 = if (rebind_is_sent)
(self.allocator.dupe(u8, canonical) catch {
self.allocator.free(sent);
return;
})
else
null;
self.allocator.free(self.pending_sent_path);
self.pending_sent_path = sent;
if (rebind) |value| {
self.allocator.free(self.pending_rebind_path);
self.pending_rebind_path = value;
}
}

fn sendPendingOpen(self: *App) void {
if (self.pending_rebind_path.len == 0 or
self.client.connectionState() != .connected or
Expand Down Expand Up @@ -1485,6 +1517,7 @@ pub const App = struct {
const path = Wire.copyGraphChangedProjectPath(self.allocator, frame) catch null;
if (path) |value| {
incoming_project_path = value;
self.adoptCanonicalOpenPath(frame, value);
if (self.client.protocolMode() == .v1 and self.pending_open_sent) {
if (!std.mem.eql(u8, value, self.pending_sent_path) and
!std.mem.eql(u8, value, self.accepted_subscription)) return;
Expand Down Expand Up @@ -1611,7 +1644,8 @@ pub const App = struct {
if (self.client.protocolMode() == .v2) {
const request_id = self.pending_open_request_id orelse return;
const response_id = Wire.responseRequestID(frame) orelse return;
if (!std.mem.eql(u8, response_id, &request_id)) return;
// The daemon echoes the UUID in uppercase.
if (!std.ascii.eqlIgnoreCase(response_id, &request_id)) return;
} else if (!self.pending_open_sent) {
return;
}
Expand Down Expand Up @@ -10350,6 +10384,76 @@ test "graph publication v1 queued opens accept the sent owner before the newest
try F.expectPublished();
}

test "graph publication adopts the daemon canonical path for the shell's own folder open" {
const F = GraphPublicationTest;
for ([_]Wire.ProtocolMode{ .v1, .v2 }) |mode| {
var app = try F.init(mode);
defer F.deinit(&app);
try F.seed(&app);
// The native folder picker returns a backslash path; the production daemon
// replies with its canonical forward-slash spelling of the same folder.
app.openProjectWithLayout("C:\\Fixtures\\Core", F.layout);
app.client.state = .connected;
app.sendPendingOpen();
try std.testing.expect(app.pending_open_sent);
const request = app.pending_open_request_id;
try F.takeOpen(&app, "C:\\Fixtures\\Core");
if (mode == .v2) {
// An uncorrelated publication for the canonical spelling is not adopted.
app.onFrameWithEffects(
\\{"version":2,"kind":"event","sequence":4,"event":{"graphChanged":{"_0":{"project":{"path":"C:/Fixtures/Core","name":"Core"},"nodes":[],"edges":[]}}}}
, F.rebind, F.refresh, F.publish);
try std.testing.expect(app.model.graphFor("C:/Fixtures/Core") == null);
try std.testing.expectEqualStrings("C:\\Fixtures\\Core", app.pending_rebind_path);
}
const reply = if (mode == .v2)
try std.fmt.allocPrint(app.allocator, "{{\"version\":2,\"kind\":\"response\",\"requestID\":\"{s}\",\"event\":{{\"graphChanged\":{{\"_0\":{{\"project\":{{\"path\":\"C:/Fixtures/Core\",\"name\":\"Core\"}},\"nodes\":[],\"edges\":[]}}}}}}}}", .{&upperRequestID(request.?)})
else
try app.allocator.dupe(u8,
\\{"version":1,"kind":"event","event":{"graphChanged":{"_0":{"project":{"path":"C:/Fixtures/Core","name":"Core"},"nodes":[],"edges":[]}}}}
);
defer app.allocator.free(reply);
app.onFrameWithEffects(reply, F.rebind, F.refresh, F.publish);
const current = app.model.currentGraph() orelse return error.FolderOpenGraphDropped;
try std.testing.expectEqualStrings("C:/Fixtures/Core", current.project.path);
try std.testing.expectEqualStrings("Core", current.project.name);
try std.testing.expectEqualStrings("C:/Fixtures/Core", app.accepted_subscription);
try std.testing.expectEqualStrings("C:/Fixtures/Core", app.client.subscription_path);
try std.testing.expectEqualStrings("C:/Fixtures/Core", app.last_project_opened);
try std.testing.expectEqual(@as(usize, 0), app.pending_rebind_path.len);
try std.testing.expectEqual(@as(usize, 0), app.pending_sent_path.len);
try std.testing.expect(!app.pending_open_sent and !app.open_project_pending);
try std.testing.expectEqual(@as(usize, 0), app.ingress_error.len);
}
}

/// Swift's `UUID.uuidString` echoes request IDs in uppercase.
fn upperRequestID(request: [36]u8) [36]u8 {
var upper: [36]u8 = undefined;
for (request, 0..) |byte, index| upper[index] = std.ascii.toUpper(byte);
return upper;
}

test "graph publication v2 open rejection correlates the daemon's uppercase request ID" {
const F = GraphPublicationTest;
var app = try F.init(.v2);
defer F.deinit(&app);
try F.seed(&app);
app.openProjectWithLayout("B", F.layout);
app.client.state = .connected;
app.sendPendingOpen();
const request = app.pending_open_request_id.?;
try F.takeOpen(&app, "B");
try std.testing.expect(!std.mem.eql(u8, &request, &upperRequestID(request)));
const rejection = try std.fmt.allocPrint(app.allocator, "{{\"version\":2,\"kind\":\"response\",\"requestID\":\"{s}\",\"event\":{{\"errorOccurred\":\"B rejected\"}}}}", .{&upperRequestID(request)});
defer app.allocator.free(rejection);
app.onFrameWithEffects(rejection, F.rebind, F.refresh, F.publish);
try std.testing.expectEqual(@as(usize, 0), app.pending_rebind_path.len);
try std.testing.expect(!app.pending_open_sent and !app.open_project_pending);
try std.testing.expectEqualStrings("A", app.accepted_subscription);
try std.testing.expectEqualStrings("B rejected", app.ingress_error);
}

test "graph publication v2 superseded opens reject stale graphs and errors without losing latest intent" {
const F = GraphPublicationTest;
var app = try F.init(.v2);
Expand Down
23 changes: 21 additions & 2 deletions graphcode-windows/src/DaemonRoundTripTests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ const Probe = struct {
last_kind: Wire.EventKind = .unknown,
invalid_response: bool = false,
model_error: bool = false,
last_request_id: [36]u8 = undefined,
last_request_id_len: usize = 0,
last_response_path: [512]u8 = undefined,
last_response_path_len: usize = 0,

fn init() Probe {
return .{ .model = GraphModel.Model.init(allocator) };
Expand All @@ -64,9 +68,17 @@ const Probe = struct {
fn receive(context: ?*anyopaque, frame_ptr: [*]const u8, length: usize) callconv(.c) void {
const self: *Probe = @ptrCast(@alignCast(context orelse return));
const frame = frame_ptr[0..length];
if (Wire.responseRequestID(frame) != null) {
if (Wire.responseRequestID(frame)) |request_id| {
self.response_count += 1;
self.last_kind = Wire.eventKind(frame);
self.last_request_id_len = @min(request_id.len, self.last_request_id.len);
@memcpy(self.last_request_id[0..self.last_request_id_len], request_id[0..self.last_request_id_len]);
self.last_response_path_len = 0;
if (Wire.copyGraphChangedProjectPath(std.heap.page_allocator, frame) catch null) |path| {
defer std.heap.page_allocator.free(path);
self.last_response_path_len = @min(path.len, self.last_response_path.len);
@memcpy(self.last_response_path[0..self.last_response_path_len], path[0..self.last_response_path_len]);
}
const parsed = std.json.parseFromSlice(
std.json.Value,
std.heap.page_allocator,
Expand Down Expand Up @@ -141,11 +153,18 @@ fn connectProject(client: *DaemonClient, probe: *Probe, path: []const u8) !void
}

const before = probe.response_count;
if (client.sendOpenProject(path) == null) return error.OpenProjectQueueRejected;
const token = client.sendOpenProject(path) orelse return error.OpenProjectQueueRejected;
try waitForAcceptedResponse(client, probe, before);
try std.testing.expect(!probe.model_error);
const canonical_path = try std.mem.replaceOwned(u8, allocator, path, "\\", "/");
defer allocator.free(canonical_path);
// The shell's folder open sends the picker's backslash spelling; the daemon
// answers that exact request with its canonical forward-slash spelling.
try std.testing.expect(std.mem.indexOfScalar(u8, path, '\\') != null);
try std.testing.expect(std.ascii.eqlIgnoreCase(&token, probe.last_request_id[0..probe.last_request_id_len]));
try std.testing.expectEqualStrings(canonical_path, probe.last_response_path[0..probe.last_response_path_len]);
try std.testing.expect(!std.mem.eql(u8, path, canonical_path));
std.debug.print("DAEMON_OPEN_CANONICAL: request correlated; picker spelling differs from daemon canonical path\n", .{});
try std.testing.expectEqualStrings(canonical_path, probe.model.currentGraph().?.project.path);
const expected_name = std.fs.path.basename(canonical_path);
try std.testing.expect(expected_name.len != 0);
Expand Down
19 changes: 19 additions & 0 deletions graphcode-windows/src/Wire.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1120,6 +1120,25 @@ pub fn isCurrentGraphPath(pending: []const u8, accepted: []const u8, path: []con
return pending.len == 0 or std.mem.eql(u8, path, pending) or std.mem.eql(u8, path, accepted);
}

/// Whether two local Windows project paths name the same folder spelling apart from
/// separator choice and ASCII case, as the daemon canonicalizes `C:\a` to `C:/a`.
pub fn sameLocalProjectPath(a: []const u8, b: []const u8) bool {
if (a.len != b.len) return false;
for (a, b) |left, right| {
const l = if (left == '\\') '/' else std.ascii.toLower(left);
const r = if (right == '\\') '/' else std.ascii.toLower(right);
if (l != r) return false;
}
return true;
}

test "local project path equivalence ignores separators and ASCII case only" {
try std.testing.expect(sameLocalProjectPath("C:\\GraphCode-Fixtures\\Core", "C:/GraphCode-Fixtures/Core"));
try std.testing.expect(sameLocalProjectPath("c:\\fixtures\\core", "C:/Fixtures/Core"));
try std.testing.expect(!sameLocalProjectPath("C:\\Fixtures\\Core", "C:/Fixtures/Core2"));
try std.testing.expect(!sameLocalProjectPath("C:\\Fixtures\\Core", "D:/Fixtures/Core"));
}

pub fn jsonNumber(data: []const u8, key: []const u8) ?u64 {
var needle_buffer: [128]u8 = undefined;
if (key.len + 3 > needle_buffer.len) return null;
Expand Down
Loading