Skip to content

Commit 8139d73

Browse files
soilSpoonampagent
andcommitted
fix: Target.attachToTarget returns unique session id per call
Target.attachToTarget was reusing the existing bc.session_id when one was already set (via the `orelse` pattern). The CDP spec requires a fresh unique sessionId per attachToTarget call, as a single target can have multiple sessions attached simultaneously. This broke Playwright's ctx.newCDPSession(page) flow: the returned sessionId was the same as the page's existing session, so Playwright's createChildSession() overwrote _sessions[sid] with a new empty object, orphaning the page session's pending callbacks and triggering assert(!object.id). Changes: - doAttachtoTarget: always call session_id_gen.next(). First attach sets bc.session_id (primary), subsequent attach sets bc.alt_session_id. - Copy session IDs into fixed buffers (session_id_buf / alt_session_id_buf) because session_id_gen.next() reuses its internal buffer and previous slices would be invalidated. - attachToBrowserTarget: also copy into session_id_buf for safety. - isValidSessionId: accept both primary and alt session IDs. - callInspector / onInspectorResponse: route V8 inspector replies back to the requesting session via inspector_reply_session. - runtime.zig sendInspector: pass cmd.input.session_id to callInspector. - Add test: "attachToTarget returns unique sessionId per call" verifying two successive attach calls produce different session IDs. Amp-Thread-ID: https://ampcode.com/threads/T-019d5277-a92a-736b-bcb4-8aa78109c21d Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d5277-a92a-736b-bcb4-8aa78109c21d Co-authored-by: Amp <amp@ampcode.com>
1 parent b6020e4 commit 8139d73

4 files changed

Lines changed: 103 additions & 26 deletions

File tree

src/cdp/CDP.zig

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -263,8 +263,13 @@ fn dispatchCommand(command: *Command, method: []const u8) !void {
263263

264264
fn isValidSessionId(self: *const CDP, input_session_id: []const u8) bool {
265265
const browser_context = &(self.browser_context orelse return false);
266-
const session_id = browser_context.session_id orelse return false;
267-
return std.mem.eql(u8, session_id, input_session_id);
266+
if (browser_context.session_id) |sid| {
267+
if (std.mem.eql(u8, sid, input_session_id)) return true;
268+
}
269+
if (browser_context.alt_session_id) |alt| {
270+
if (std.mem.eql(u8, alt, input_session_id)) return true;
271+
}
272+
return false;
268273
}
269274

270275
pub fn createBrowserContext(self: *CDP) ![]const u8 {
@@ -351,7 +356,19 @@ pub const BrowserContext = struct {
351356
// is all pretty straightforward, but it still needs to be enforced, i.e.
352357
// if we get a request with a sessionId that doesn't match the current one
353358
// we should reject it.
354-
session_id: ?[]const u8,
359+
// session_id_gen.next() reuses its internal buffer, so slices returned
360+
// by it are only valid until the next call. We copy into fixed buffers
361+
// so that session IDs remain stable across multiple next() calls.
362+
session_id: ?[]const u8 = null,
363+
session_id_buf: [14]u8 = undefined,
364+
365+
// Additional session ID from explicit Target.attachToTarget calls.
366+
alt_session_id: ?[]const u8 = null,
367+
alt_session_id_buf: [14]u8 = undefined,
368+
369+
// Tracks which session to reply to for in-flight inspector commands.
370+
// Set before callInspector, cleared after runMicrotasks.
371+
inspector_reply_session: ?[]const u8 = null,
355372

356373
security_origin: []const u8,
357374
page_life_cycle_events: bool,
@@ -701,13 +718,17 @@ pub const BrowserContext = struct {
701718
defer _ = self.cdp.notification_arena.reset(.{ .retain_with_limit = 1024 * 64 });
702719
}
703720

704-
pub fn callInspector(self: *const BrowserContext, msg: []const u8) void {
721+
pub fn callInspector(self: *BrowserContext, msg: []const u8, reply_session_id: ?[]const u8) void {
722+
self.inspector_reply_session = reply_session_id;
705723
self.inspector_session.send(msg);
706724
self.session.browser.env.runMicrotasks();
725+
self.inspector_reply_session = null;
707726
}
708727

709728
pub fn onInspectorResponse(ctx: *anyopaque, _: u32, msg: []const u8) void {
710-
sendInspectorMessage(@ptrCast(@alignCast(ctx)), msg) catch |err| {
729+
const bc: *BrowserContext = @ptrCast(@alignCast(ctx));
730+
const target_session = bc.inspector_reply_session orelse bc.session_id;
731+
sendInspectorMessageTo(bc, msg, target_session) catch |err| {
711732
log.err(.cdp, "send inspector response", .{ .err = err });
712733
};
713734
}
@@ -733,7 +754,11 @@ pub const BrowserContext = struct {
733754
// session_id onto it. Second, we're much more client/websocket aware than
734755
// we should be.
735756
fn sendInspectorMessage(self: *BrowserContext, msg: []const u8) !void {
736-
const session_id = self.session_id orelse {
757+
return sendInspectorMessageTo(self, msg, self.session_id);
758+
}
759+
760+
fn sendInspectorMessageTo(self: *BrowserContext, msg: []const u8, session_id: ?[]const u8) !void {
761+
const sid = session_id orelse {
737762
// We no longer have an active session. What should we do
738763
// in this case?
739764
return;
@@ -746,7 +771,7 @@ pub const BrowserContext = struct {
746771

747772
// + 1 for the closing quote after the session id
748773
// + 10 for the max websocket header
749-
const message_len = msg.len + session_id.len + 1 + field.len + 10;
774+
const message_len = msg.len + sid.len + 1 + field.len + 10;
750775

751776
var buf: std.ArrayList(u8) = .{};
752777
buf.ensureTotalCapacity(allocator, message_len) catch |err| {
@@ -760,7 +785,7 @@ pub const BrowserContext = struct {
760785
// -1 because we dont' want the closing brace '}'
761786
buf.appendSliceAssumeCapacity(msg[0 .. msg.len - 1]);
762787
buf.appendSliceAssumeCapacity(field);
763-
buf.appendSliceAssumeCapacity(session_id);
788+
buf.appendSliceAssumeCapacity(sid);
764789
buf.appendSliceAssumeCapacity("\"}");
765790
if (comptime IS_DEBUG) {
766791
std.debug.assert(buf.items.len == message_len);

src/cdp/domains/page.zig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ fn close(cmd: *CDP.Command) !void {
228228
}, .{});
229229

230230
bc.session_id = null;
231+
bc.alt_session_id = null;
231232
}
232233

233234
bc.session.removePage();

src/cdp/domains/runtime.zig

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ fn sendInspector(cmd: *CDP.Command, action: anytype) !void {
4747
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
4848

4949
// the result to return is handled directly by the inspector.
50-
bc.callInspector(cmd.input.json);
50+
// Pass the requesting session id so the response is routed back correctly,
51+
// even when an alt_session_id (from Target.attachToTarget) is in use.
52+
bc.callInspector(cmd.input.json, cmd.input.session_id);
5153
}
5254

5355
fn logInspector(cmd: *CDP.Command, action: anytype) !void {

src/cdp/domains/target.zig

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -246,16 +246,21 @@ fn attachToTarget(cmd: *CDP.Command) !void {
246246

247247
try doAttachtoTarget(cmd, target_id);
248248

249-
return cmd.sendResult(.{ .sessionId = bc.session_id }, .{});
249+
// Return the newly assigned session id (alt if secondary, primary if first attach).
250+
return cmd.sendResult(.{ .sessionId = bc.alt_session_id orelse bc.session_id }, .{});
250251
}
251252

252253
fn attachToBrowserTarget(cmd: *CDP.Command) !void {
253254
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
254255

255-
const session_id = bc.session_id orelse cmd.cdp.session_id_gen.next();
256+
if (bc.session_id == null) {
257+
const session_id = cmd.cdp.session_id_gen.next();
258+
@memcpy(bc.session_id_buf[0..session_id.len], session_id);
259+
bc.session_id = bc.session_id_buf[0..session_id.len];
260+
}
256261

257262
try cmd.sendEvent("Target.attachedToTarget", AttachToTarget{
258-
.sessionId = session_id,
263+
.sessionId = bc.session_id.?,
259264
.targetInfo = TargetInfo{
260265
.targetId = bc.id, // We use the browser context is as browser's target id.
261266
.title = "",
@@ -266,8 +271,6 @@ fn attachToBrowserTarget(cmd: *CDP.Command) !void {
266271
},
267272
}, .{});
268273

269-
bc.session_id = session_id;
270-
271274
return cmd.sendResult(.{ .sessionId = bc.session_id }, .{});
272275
}
273276

@@ -302,6 +305,7 @@ fn closeTarget(cmd: *CDP.Command) !void {
302305
}, .{});
303306

304307
bc.session_id = null;
308+
bc.alt_session_id = null;
305309
}
306310

307311
bc.session.removePage();
@@ -387,6 +391,7 @@ fn detachFromTarget(cmd: *CDP.Command) !void {
387391
}, .{});
388392
}
389393
bc.session_id = null;
394+
bc.alt_session_id = null;
390395
}
391396

392397
return cmd.sendResult(null, .{});
@@ -417,6 +422,7 @@ fn setAutoAttach(cmd: *CDP.Command) !void {
417422
}, .{});
418423
}
419424
bc.session_id = null;
425+
bc.alt_session_id = null;
420426
}
421427
try cmd.sendResult(null, .{});
422428
return;
@@ -459,14 +465,10 @@ fn setAutoAttach(cmd: *CDP.Command) !void {
459465

460466
fn doAttachtoTarget(cmd: *CDP.Command, target_id: []const u8) !void {
461467
const bc = cmd.browser_context.?;
462-
const session_id = bc.session_id orelse cmd.cdp.session_id_gen.next();
463-
464-
if (bc.session_id == null) {
465-
// extra_headers should not be kept on a new page or tab,
466-
// currently we have only 1 page, we clear it just in case
467-
bc.extra_headers.clearRetainingCapacity();
468-
}
468+
const session_id = cmd.cdp.session_id_gen.next();
469469

470+
// Send the event from the *parent* session (bc.session_id before update),
471+
// matching original behaviour: first-attach events are root-level (no sessionId).
470472
try cmd.sendEvent("Target.attachedToTarget", AttachToTarget{
471473
.sessionId = session_id,
472474
.targetInfo = TargetInfo{
@@ -477,7 +479,21 @@ fn doAttachtoTarget(cmd: *CDP.Command, target_id: []const u8) !void {
477479
},
478480
}, .{ .session_id = bc.session_id });
479481

480-
bc.session_id = session_id;
482+
// session_id_gen.next() reuses its internal buffer, so we must copy
483+
// the value into a stable buffer before it gets overwritten.
484+
if (bc.session_id == null) {
485+
// First attach: set as primary session.
486+
// extra_headers should not be kept on a new page or tab,
487+
// currently we have only 1 page, we clear it just in case
488+
bc.extra_headers.clearRetainingCapacity();
489+
@memcpy(bc.session_id_buf[0..session_id.len], session_id);
490+
bc.session_id = bc.session_id_buf[0..session_id.len];
491+
} else {
492+
// Subsequent attach (e.g. explicit Target.attachToTarget from a CDP client):
493+
// keep the primary session_id intact so existing page operations remain valid.
494+
@memcpy(bc.alt_session_id_buf[0..session_id.len], session_id);
495+
bc.alt_session_id = bc.alt_session_id_buf[0..session_id.len];
496+
}
481497
}
482498

483499
const AttachToTarget = struct {
@@ -671,6 +687,34 @@ test "cdp.target: attachToTarget" {
671687
}
672688
}
673689

690+
test "cdp.target: attachToTarget returns unique sessionId per call" {
691+
var ctx = try testing.context();
692+
defer ctx.deinit();
693+
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
694+
_ = try bc.session.createPage();
695+
bc.target_id = "TID-000000000D".*;
696+
697+
// First attach — sets primary session_id.
698+
try ctx.processMessage(.{ .id = 20, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-000000000D" } });
699+
const first_sid = bc.session_id.?;
700+
// Event is sent before result; drain the event first.
701+
try ctx.expectSentEvent("Target.attachedToTarget", .{ .sessionId = first_sid, .targetInfo = .{ .url = "about:blank", .title = "", .attached = true, .type = "page", .canAccessOpener = false, .browserContextId = "BID-9", .targetId = bc.target_id.? } }, .{});
702+
try ctx.expectSentResult(.{ .sessionId = first_sid }, .{ .id = 20 });
703+
704+
// Second attach — must return a *different* session id (stored as alt_session_id).
705+
// The event is tagged with the *primary* session (first_sid), not the new one.
706+
try ctx.processMessage(.{ .id = 21, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-000000000D" } });
707+
const second_sid = bc.alt_session_id.?;
708+
try ctx.expectSentEvent("Target.attachedToTarget", .{ .sessionId = second_sid, .targetInfo = .{ .url = "about:blank", .title = "", .attached = true, .type = "page", .canAccessOpener = false, .browserContextId = "BID-9", .targetId = bc.target_id.? } }, .{ .session_id = first_sid });
709+
try ctx.expectSentResult(.{ .sessionId = second_sid }, .{ .id = 21 });
710+
711+
// The two session ids must differ.
712+
try testing.expect(!std.mem.eql(u8, first_sid, second_sid));
713+
714+
// Primary session must remain unchanged.
715+
try testing.expectEqualSlices(u8, first_sid, bc.session_id.?);
716+
}
717+
674718
test "cdp.target: getTargetInfo" {
675719
var ctx = try testing.context();
676720
defer ctx.deinit();
@@ -750,13 +794,18 @@ test "cdp.target: detachFromTarget" {
750794
const session_id = bc.session_id.?;
751795
try ctx.expectSentResult(.{ .sessionId = session_id }, .{ .id = 11 });
752796

753-
try ctx.processMessage(.{ .id = 12, .method = "Target.detachFromTarget", .params = .{ .targetId = bc.target_id.? } });
797+
// Second attach to create alt_session_id.
798+
try ctx.processMessage(.{ .id = 12, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } });
799+
try testing.expect(bc.alt_session_id != null);
800+
801+
try ctx.processMessage(.{ .id = 13, .method = "Target.detachFromTarget", .params = .{ .targetId = bc.target_id.? } });
754802
try ctx.expectSentEvent("Target.detachedFromTarget", .{ .sessionId = session_id }, .{});
755803
try testing.expectEqual(null, bc.session_id);
756-
try ctx.expectSentResult(null, .{ .id = 12 });
804+
try testing.expectEqual(null, bc.alt_session_id);
805+
try ctx.expectSentResult(null, .{ .id = 13 });
757806

758-
try ctx.processMessage(.{ .id = 13, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } });
759-
try ctx.expectSentResult(.{ .sessionId = bc.session_id.? }, .{ .id = 13 });
807+
try ctx.processMessage(.{ .id = 14, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } });
808+
try ctx.expectSentResult(.{ .sessionId = bc.session_id.? }, .{ .id = 14 });
760809
}
761810
}
762811

0 commit comments

Comments
 (0)