Skip to content

Conversation

realrajaryan
Copy link
Contributor

@realrajaryan realrajaryan commented Sep 17, 2025

Addresses apple/container#141, where containers don't receive filesystem events on mounted volumes, preventing incremental rebuilds and other file-watching features. This PR implements the guest-side components for FSNotify. Host-side implementation in the container repo will complete the pipeline.

Summary:

  • Add gRPC protocol definitions for filesystem event notifications
  • Implement guest-side event handler that generates Linux inotify events
  • Add CLI testing tool (cctl fsnotify) and integration test infrastructure

@realrajaryan realrajaryan force-pushed the users/realrajaryan/fsnotify branch from f89a058 to 128421e Compare September 17, 2025 22:35
@realrajaryan realrajaryan force-pushed the users/realrajaryan/fsnotify branch from 128421e to 816c698 Compare September 17, 2025 23:14
@dcantah
Copy link
Member

dcantah commented Sep 19, 2025

@realrajaryan Can you take a peek at the CI fail, it's for the new test added

@realrajaryan realrajaryan force-pushed the users/realrajaryan/fsnotify branch 4 times, most recently from b33caa9 to 07a7db3 Compare October 2, 2025 05:49
LINK = 2;
UNLINK = 3;
MODIFY = 4;
UNDEFINED = 99;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does an UNDEFINED event represent? When would such an event be propagated through the gRPC API?

message NotifyFileSystemEventRequest {
string path = 1;
FileSystemEventType event_type = 2;
string container_id = 3;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not clear why, but it seems we use snake case for just about everything in this file, except a container ID is containerID 🤷

}

/// Send a filesystem event notification to the guest.
public func notifyFileSystemEvent(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we're using streaming, the API here should be some sort of collection of Com_Apple_Containerization_Sandbox_V3_NotifyFileSystemEventRequest (or a typealias of it).


let fs: Containerization.Mount = try await {
let fsPath = Self.testDir.appending(component: "rootfs.ext4")
let imageHash = Self.hashImageReference(reference)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just use image.digest in place of manually hashing the image reference.

Copy link
Member

@dcantah dcantah Oct 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, I'd suggested doing this verbally but I forgot we have the image object already at this point.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's good about just using the digest is in the (no idea why we'd do this but) odd scenario where it's the same image, just a different reference/tag, we still wouldn't unpack for no reason if we already had.

}
print("MODIFY event succeeded")

// Wait for events to be processed
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove all the debug prints before merging.

import NIOCore
import NIOPosix

extension Application {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this command work? What commands should I use to test it locally?

print("All FSNotify events tested successfully")
}

private func createFSNotifyTestDirectory() throws -> URL {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need this, or could we instead use createMountDirectory since we're only working with one file? If we do need to create a subdirectory we could do it as part of the test.

print("=== End Container Output ===")

// Verify that inotify detected the modify event
guard inotifyOutput.contains("change") && inotifyOutput.contains("existing.txt") else {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that this should below the delete event, and rather than checking for the presence of any change and any existing.txt, we should be ensuring that the actual output exactly matches the expected output.

private func readResponseFromChild(socket: Int32) throws -> (UInt32, UInt8)? {
// Read event_id:4
var eventID: UInt32 = 0
guard read(socket, &eventID, 4) == 4 else { return nil }
Copy link
Member

@dcantah dcantah Oct 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a mechanism to get notified of when we have events to read so it seems we're just doing blocking read(2)'s on a swift concurrency thread. For low core count guests this will likely cause us to hang 😅. You're not supposed to block any of the swift concurrency threads, as they're always supposed to be making forward progress or hitting some suspension point. We could use nio for these socket reads and that'd at least get us into shape

Copy link
Contributor

@jglogan jglogan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's get the concurrency working so that it's not @unchecked. See Danny's suggestion about using NIO for the socket communication.

private var namespaceWorker: NamespaceWorker?

/// Worker child process that runs in container's namespace for filesystem operations
private final class NamespaceWorker: @unchecked Sendable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few things here:

  • Is there a reason for nesting this class? It's adding about 350 lines to ManagedContainer, which makes it harder for someone unfamiliar with the code to reason about either. I can move it outside of ManagedContainer and still compile it, so let's move it into its own file.
  • I could see this being called NamespaceWorker if it facilitated any kind of work taking place between the guest agent and namespace, which could be a useful abstraction later on. For now let's change the name to describe specifically the work being done.
  • We need to manage the concurrency between guest agent, worker parent, and worker child such that @unchecked isn't needed for Sendable. In particular, we can't do blocking socket read() calls in Task context.

throw ContainerizationError(.internalError, message: "Child process failed to start")
}

if signal == 0xFF {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extract constants for these and then you can get rid of the comments.

Also, let's not call it a "signal" as that has a specific meaning that is not this.

}

// Start response reader task
self.responseReaderTask = Task { [weak self] in
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is no different from:

            self.responseReaderTask = Task {
                await self.readChildResponses()
            }

@realrajaryan realrajaryan force-pushed the users/realrajaryan/fsnotify branch from 11e1720 to eb651c5 Compare October 9, 2025 00:07
import Glibc
#endif

final class FilesystemEventWorker: @unchecked Sendable {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should try and make this actually sendable. Just shove all the mutable state in a Mutex (we can reuse the one we have for pendingEvents, but just make a struct with all of this data on it, and call the field state or something similar.

let parentSocket = sockets[0]
let childSocket = sockets[1]

let pid = fork()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm skeptical this will work alright. Here's a decent article on some of the pitfalls https://thorstenball.com/blog/2014/10/13/why-threads-cant-fork/

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'd ideally want to exec a new process that we pass the other half of the socket pair to.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(the new process does not physically need to be a new binary, we can just edit vminitd to have a fs-notify subcommand and fork+exec ourselves)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants